chore: merge remote-tracking branch 'origin/dev' into agent3
This commit is contained in:
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -26,7 +26,7 @@ env:
|
|||||||
# staging repo (autumn-staging) -> us-east-1
|
# staging repo (autumn-staging) -> us-east-1
|
||||||
# Branches allowed to deploy to staging via workflow_dispatch with tag=deploy-staging.
|
# Branches allowed to deploy to staging via workflow_dispatch with tag=deploy-staging.
|
||||||
# Add short-lived PR branches here when you need staging without merging to dev.
|
# Add short-lived PR branches here when you need staging without merging to dev.
|
||||||
STAGING_DEPLOY_BRANCH_ALLOWLIST: ""
|
STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis feat/events-hourly-rollup fix/analytics-tz-bucket-offset uw-1-storage
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
checks:
|
checks:
|
||||||
|
|||||||
@@ -34,8 +34,7 @@
|
|||||||
"e2b": "^2.8.4",
|
"e2b": "^2.8.4",
|
||||||
"hono": "4.12.7",
|
"hono": "4.12.7",
|
||||||
"postgres": "catalog:",
|
"postgres": "catalog:",
|
||||||
"zod": "^3.25.23",
|
"zod": "^3.25.23"
|
||||||
"zod-v4": "npm:zod@^4.4.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ngrok/ngrok": "^1.7.0",
|
"@ngrok/ngrok": "^1.7.0",
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ const envSchema = z
|
|||||||
return {
|
return {
|
||||||
...values,
|
...values,
|
||||||
MCP_SERVER_URL:
|
MCP_SERVER_URL:
|
||||||
values.MCP_SERVER_URL ?? `http://localhost:${values.PORT}`,
|
values.MCP_SERVER_URL ??
|
||||||
|
(process.env.NODE_ENV === "production"
|
||||||
|
? "https://mcp.useautumn.com/mcp"
|
||||||
|
: `http://localhost:${values.PORT}`),
|
||||||
BETTER_AUTH_URL:
|
BETTER_AUTH_URL:
|
||||||
values.BETTER_AUTH_URL ??
|
values.BETTER_AUTH_URL ??
|
||||||
(process.env.NODE_ENV === "production"
|
(process.env.NODE_ENV === "production"
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ app.use("*", async (c, next) => {
|
|||||||
|
|
||||||
app.get("/health", (c) => c.json({ ok: true }));
|
app.get("/health", (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app.route(
|
app.route(
|
||||||
"",
|
"",
|
||||||
createMcpRouter({
|
createMcpRouter({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type AutumnEvalToolName =
|
|||||||
| "getCustomer"
|
| "getCustomer"
|
||||||
| "getEntity"
|
| "getEntity"
|
||||||
| "getOrCreateCustomer"
|
| "getOrCreateCustomer"
|
||||||
|
| "getCurrentOrganization"
|
||||||
| "getPlan"
|
| "getPlan"
|
||||||
| "listCustomers"
|
| "listCustomers"
|
||||||
| "listEntities"
|
| "listEntities"
|
||||||
|
|||||||
@@ -196,9 +196,21 @@ const matchesApiCall = ({
|
|||||||
actual.toolName === expected.toolName &&
|
actual.toolName === expected.toolName &&
|
||||||
(!expected.body || includesObject(actual.body, expected.body));
|
(!expected.body || includesObject(actual.body, expected.body));
|
||||||
|
|
||||||
const valuesAtPath = ({ path, value }: { path: string; value: unknown }) => {
|
const valuesAtPath = ({
|
||||||
|
path,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
value: unknown;
|
||||||
|
}): unknown[] => {
|
||||||
const parts = path.split(".");
|
const parts = path.split(".");
|
||||||
const walk = ({ index, current }: { index: number; current: unknown }) => {
|
const walk = ({
|
||||||
|
index,
|
||||||
|
current,
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
current: unknown;
|
||||||
|
}): unknown[] => {
|
||||||
if (index === parts.length) return [current];
|
if (index === parts.length) return [current];
|
||||||
const part = parts[index];
|
const part = parts[index];
|
||||||
if (part === "*") {
|
if (part === "*") {
|
||||||
@@ -343,7 +355,7 @@ export const expectedApiBodyNumberFields = ({
|
|||||||
const values = valuesAtPath({ path, value: call.body });
|
const values = valuesAtPath({ path, value: call.body });
|
||||||
return (
|
return (
|
||||||
values.length > 0 &&
|
values.length > 0 &&
|
||||||
values.every((value) => typeof value === "number")
|
values.every((value: unknown) => typeof value === "number")
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
||||||
// SDK tool() needs zod v4 internals; leaf is on zod 3, so use the aliased package.
|
// SDK tool() needs v4 schemas from the same zod copy its peer resolves to (leaf's zod 3.25.x).
|
||||||
import { z } from "zod-v4";
|
import { z } from "zod/v4";
|
||||||
import { createClaudeCodeHarness } from "../../src/harness/index.js";
|
import { createClaudeCodeHarness } from "../../src/harness/index.js";
|
||||||
import type {
|
import type {
|
||||||
HarnessEvent,
|
HarnessEvent,
|
||||||
|
|||||||
@@ -41,13 +41,11 @@ COPY packages/stripe-sync/package.json packages/stripe-sync/
|
|||||||
# install step doesn't fail before the real source is copied.
|
# install step doesn't fail before the real source is copied.
|
||||||
RUN mkdir -p scripts && touch scripts/preload-env.ts
|
RUN mkdir -p scripts && touch scripts/preload-env.ts
|
||||||
|
|
||||||
# Install only the workspaces the runtime services need (server hosts workers +
|
# Install the full workspace because runtime source imports cross package boundaries.
|
||||||
# cron), plus their transitive workspace deps. --frozen-lockfile guarantees no
|
# --no-save keeps this image layer from mutating bun.lock.
|
||||||
# re-resolution; --filter skips the frontend-heavy workspaces.
|
|
||||||
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||||
bun install --frozen-lockfile --ignore-scripts \
|
bun install --ignore-scripts --no-save \
|
||||||
--filter @autumn/server \
|
--minimum-release-age 0
|
||||||
--filter @autumn/leaf
|
|
||||||
|
|
||||||
FROM oven/bun:1.3.10
|
FROM oven/bun:1.3.10
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ts": "tsgo --noEmit --skipLibCheck",
|
"ts": "bunx tsgo --noEmit --skipLibCheck",
|
||||||
"test": "bun test tests/unit",
|
"test": "bun test tests/unit",
|
||||||
"build": "rm -rf dist && tsup",
|
"build": "rm -rf dist && tsup",
|
||||||
"prepublishOnly": "bun run build"
|
"prepublishOnly": "bun run build"
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.9.1",
|
"@types/node": "^24.9.1",
|
||||||
|
"@typescript/native-preview": "catalog:",
|
||||||
"tsup": "^8.4.0",
|
"tsup": "^8.4.0",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ export const initMcpEval = ({
|
|||||||
};
|
};
|
||||||
const generate = async (
|
const generate = async (
|
||||||
message: string | string[],
|
message: string | string[],
|
||||||
maxSteps = leafChatAgentDefaults.maxSteps,
|
maxSteps: number = leafChatAgentDefaults.maxSteps,
|
||||||
) => {
|
) => {
|
||||||
messages.push({
|
messages.push({
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -293,7 +293,7 @@ export const initMcpEval = ({
|
|||||||
generate,
|
generate,
|
||||||
approve: async (
|
approve: async (
|
||||||
message: string,
|
message: string,
|
||||||
maxSteps = leafChatAgentDefaults.maxSteps,
|
maxSteps: number = leafChatAgentDefaults.maxSteps,
|
||||||
) => {
|
) => {
|
||||||
if (!pendingApproval) await generate(message, maxSteps);
|
if (!pendingApproval) await generate(message, maxSteps);
|
||||||
if (!pendingApproval) {
|
if (!pendingApproval) {
|
||||||
|
|||||||
@@ -21,6 +21,42 @@ import { ensureEmulateRunning } from "../helpers/emulate.ts";
|
|||||||
import { PROJECT_ROOT } from "../constants.ts";
|
import { PROJECT_ROOT } from "../constants.ts";
|
||||||
import type { RegistryEntry } from "../types.ts";
|
import type { RegistryEntry } from "../types.ts";
|
||||||
|
|
||||||
|
function ensureAiSubmoduleSynced(): void {
|
||||||
|
const aiDir = `${PROJECT_ROOT}/ai`;
|
||||||
|
|
||||||
|
log("ensuring ai submodule is initialized");
|
||||||
|
const submoduleCode = shInherit(
|
||||||
|
"git",
|
||||||
|
["submodule", "update", "--init", "--recursive"],
|
||||||
|
{ cwd: PROJECT_ROOT },
|
||||||
|
);
|
||||||
|
if (submoduleCode !== 0) {
|
||||||
|
fatal(
|
||||||
|
`git submodule update --init --recursive failed (exit ${submoduleCode})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log("checking out ai submodule main branch");
|
||||||
|
const checkoutCode = shInherit("git", ["checkout", "main"], {
|
||||||
|
cwd: aiDir,
|
||||||
|
});
|
||||||
|
if (checkoutCode !== 0) {
|
||||||
|
fatal(`git checkout main failed in ai submodule (exit ${checkoutCode})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log("ensuring ai deps installed (bun install)");
|
||||||
|
const installCode = shInherit("bun", ["install"], { cwd: aiDir });
|
||||||
|
if (installCode !== 0) {
|
||||||
|
fatal(`bun install failed in ai submodule (exit ${installCode})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log("syncing ai skills");
|
||||||
|
const syncCode = shInherit("bun", ["sync"], { cwd: aiDir });
|
||||||
|
if (syncCode !== 0) {
|
||||||
|
fatal(`bun sync failed in ai submodule (exit ${syncCode})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function cmdSetup(): Promise<RegistryEntry> {
|
export async function cmdSetup(): Promise<RegistryEntry> {
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
fatal("bun dw is disabled in production");
|
fatal("bun dw is disabled in production");
|
||||||
@@ -32,6 +68,8 @@ export async function cmdSetup(): Promise<RegistryEntry> {
|
|||||||
const installCode = shInherit("bun", ["install"], { cwd: PROJECT_ROOT });
|
const installCode = shInherit("bun", ["install"], { cwd: PROJECT_ROOT });
|
||||||
if (installCode !== 0) fatal(`bun install failed (exit ${installCode})`);
|
if (installCode !== 0) fatal(`bun install failed (exit ${installCode})`);
|
||||||
|
|
||||||
|
ensureAiSubmoduleSynced();
|
||||||
|
|
||||||
const canonical = getCanonicalWorktree();
|
const canonical = getCanonicalWorktree();
|
||||||
const cwd = getCurrentWorktree();
|
const cwd = getCurrentWorktree();
|
||||||
let registry = loadRegistry();
|
let registry = loadRegistry();
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export const NEON_TEMPLATE_BRANCH = "dw-template";
|
|||||||
export const NEON_PARENT_BRANCH = "production";
|
export const NEON_PARENT_BRANCH = "production";
|
||||||
|
|
||||||
export const EMULATE_PID_FILE = join(homedir(), ".autumn-emulate.pid");
|
export const EMULATE_PID_FILE = join(homedir(), ".autumn-emulate.pid");
|
||||||
export const EMULATE_HEALTH_URL =
|
|
||||||
"https://google.emulate.localhost/.well-known/openid-configuration";
|
|
||||||
export const START_EMULATE_SH = join(SCRIPT_DIR, "../setup/start-emulate.sh");
|
export const START_EMULATE_SH = join(SCRIPT_DIR, "../setup/start-emulate.sh");
|
||||||
|
|
||||||
export const ENV_LOCAL_TARGETS = [
|
export const ENV_LOCAL_TARGETS = [
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||||
import { sh, log } from "./shell.ts";
|
import { EMULATE_PID_FILE, START_EMULATE_SH } from "../constants.ts";
|
||||||
import {
|
import { portlessHttpsUrl } from "./ports.ts";
|
||||||
EMULATE_PID_FILE,
|
import { log, sh } from "./shell.ts";
|
||||||
EMULATE_HEALTH_URL,
|
|
||||||
START_EMULATE_SH,
|
|
||||||
} from "../constants.ts";
|
|
||||||
|
|
||||||
function emulateReachable(): boolean {
|
function emulateReachable(): boolean {
|
||||||
|
const healthUrl = `${portlessHttpsUrl("google.emulate.localhost")}/.well-known/openid-configuration`;
|
||||||
const res = sh("curl", [
|
const res = sh("curl", [
|
||||||
"-sf",
|
"-sf",
|
||||||
"-o",
|
"-o",
|
||||||
"/dev/null",
|
"/dev/null",
|
||||||
"--max-time",
|
"--max-time",
|
||||||
"1",
|
"1",
|
||||||
EMULATE_HEALTH_URL,
|
healthUrl,
|
||||||
]);
|
]);
|
||||||
return res.code === 0;
|
return res.code === 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
import { existsSync, readFileSync, renameSync, writeFileSync, rmSync } from "node:fs";
|
import {
|
||||||
import { dirname, join } from "node:path";
|
existsSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import {
|
||||||
|
ENV_LOCAL_DISABLED_SUFFIX,
|
||||||
|
ENV_LOCAL_TARGETS,
|
||||||
|
PROJECT_ROOT,
|
||||||
|
} from "../constants.ts";
|
||||||
|
import type { RegistryEntry } from "../types.ts";
|
||||||
|
import {
|
||||||
|
aliasesFor,
|
||||||
|
dragonflyPortFor,
|
||||||
|
elasticMqPortFor,
|
||||||
|
portlessHttpsUrl,
|
||||||
|
} from "./ports.ts";
|
||||||
import { log } from "./shell.ts";
|
import { log } from "./shell.ts";
|
||||||
import { forceSslVerifyFull } from "./url.ts";
|
import { forceSslVerifyFull } from "./url.ts";
|
||||||
import { aliasesFor, dragonflyPortFor, elasticMqPortFor } from "./ports.ts";
|
|
||||||
import { PROJECT_ROOT, ENV_LOCAL_TARGETS, ENV_LOCAL_DISABLED_SUFFIX } from "../constants.ts";
|
|
||||||
import type { RegistryEntry } from "../types.ts";
|
|
||||||
|
|
||||||
// Simple KEY=VALUE parse (no quoting/multiline). Sufficient for .env.local
|
// Simple KEY=VALUE parse (no quoting/multiline). Sufficient for .env.local
|
||||||
// files we own end-to-end; preserves blank lines and comments untouched.
|
// files we own end-to-end; preserves blank lines and comments untouched.
|
||||||
export function parseEnvFile(contents: string): { keys: string[]; values: Record<string, string>; raw: string[] } {
|
export function parseEnvFile(contents: string): {
|
||||||
|
keys: string[];
|
||||||
|
values: Record<string, string>;
|
||||||
|
raw: string[];
|
||||||
|
} {
|
||||||
const raw = contents.split(/\r?\n/);
|
const raw = contents.split(/\r?\n/);
|
||||||
const values: Record<string, string> = {};
|
const values: Record<string, string> = {};
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
@@ -23,11 +42,14 @@ export function parseEnvFile(contents: string): { keys: string[]; values: Record
|
|||||||
return { keys, values, raw };
|
return { keys, values, raw };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeEnvFile(existing: string | null, managed: Record<string, string>): string {
|
export function mergeEnvFile(
|
||||||
|
existing: string | null,
|
||||||
|
managed: Record<string, string>,
|
||||||
|
): string {
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return Object.entries(managed)
|
return `${Object.entries(managed)
|
||||||
.map(([k, v]) => `${k}=${v}`)
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
.join("\n") + "\n";
|
.join("\n")}\n`;
|
||||||
}
|
}
|
||||||
const parsed = parseEnvFile(existing);
|
const parsed = parseEnvFile(existing);
|
||||||
const managedKeys = new Set(Object.keys(managed));
|
const managedKeys = new Set(Object.keys(managed));
|
||||||
@@ -49,7 +71,7 @@ export function mergeEnvFile(existing: string | null, managed: Record<string, st
|
|||||||
while (outLines.length > 0 && outLines[outLines.length - 1] === "") {
|
while (outLines.length > 0 && outLines[outLines.length - 1] === "") {
|
||||||
outLines.pop();
|
outLines.pop();
|
||||||
}
|
}
|
||||||
return outLines.join("\n") + "\n";
|
return `${outLines.join("\n")}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeEnvLocalFiles(entry: RegistryEntry): void {
|
export function writeEnvLocalFiles(entry: RegistryEntry): void {
|
||||||
@@ -68,7 +90,7 @@ export function writeEnvLocalFiles(entry: RegistryEntry): void {
|
|||||||
DATABASE_CRITICAL_URL: dbUrl,
|
DATABASE_CRITICAL_URL: dbUrl,
|
||||||
BETTER_AUTH_URL: aliases.apiUrl,
|
BETTER_AUTH_URL: aliases.apiUrl,
|
||||||
CLIENT_URL: aliases.viteUrl,
|
CLIENT_URL: aliases.viteUrl,
|
||||||
EMULATE_GOOGLE_URL: "https://google.emulate.localhost",
|
EMULATE_GOOGLE_URL: portlessHttpsUrl("google.emulate.localhost"),
|
||||||
AUTUMN_TEST_BASE_URL: `http://localhost:${serverPort}`,
|
AUTUMN_TEST_BASE_URL: `http://localhost:${serverPort}`,
|
||||||
AUTUMN_TEST_VITE_URL: aliases.viteUrl,
|
AUTUMN_TEST_VITE_URL: aliases.viteUrl,
|
||||||
STRIPE_WEBHOOK_SKIP_VERIFY: "true",
|
STRIPE_WEBHOOK_SKIP_VERIFY: "true",
|
||||||
@@ -135,7 +157,11 @@ export function removeEnvLocalFiles(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disableEnvLocalFiles(): { moved: number; missing: number; alreadyDisabled: number } {
|
export function disableEnvLocalFiles(): {
|
||||||
|
moved: number;
|
||||||
|
missing: number;
|
||||||
|
alreadyDisabled: number;
|
||||||
|
} {
|
||||||
let moved = 0;
|
let moved = 0;
|
||||||
let missing = 0;
|
let missing = 0;
|
||||||
let alreadyDisabled = 0;
|
let alreadyDisabled = 0;
|
||||||
@@ -156,7 +182,11 @@ export function disableEnvLocalFiles(): { moved: number; missing: number; alread
|
|||||||
return { moved, missing, alreadyDisabled };
|
return { moved, missing, alreadyDisabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enableEnvLocalFiles(): { moved: number; missing: number; alreadyEnabled: number } {
|
export function enableEnvLocalFiles(): {
|
||||||
|
moved: number;
|
||||||
|
missing: number;
|
||||||
|
alreadyEnabled: number;
|
||||||
|
} {
|
||||||
let moved = 0;
|
let moved = 0;
|
||||||
let missing = 0;
|
let missing = 0;
|
||||||
let alreadyEnabled = 0;
|
let alreadyEnabled = 0;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { sh, log } from "./shell.ts";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import type { WorktreeAliases } from "../types.ts";
|
import type { WorktreeAliases } from "../types.ts";
|
||||||
|
import { log, sh } from "./shell.ts";
|
||||||
|
|
||||||
|
const PORTLESS_PROXY_PORT_FILE = join(homedir(), ".portless", "proxy.port");
|
||||||
|
|
||||||
export function dragonflyPortFor(worktreeNum: number): number {
|
export function dragonflyPortFor(worktreeNum: number): number {
|
||||||
return 6379 + (worktreeNum - 1) * 100;
|
return 6379 + (worktreeNum - 1) * 100;
|
||||||
@@ -28,17 +33,38 @@ export function aliasesFor(worktreeNum: number): WorktreeAliases {
|
|||||||
const viteHost = `wt${worktreeNum}.localhost`;
|
const viteHost = `wt${worktreeNum}.localhost`;
|
||||||
return {
|
return {
|
||||||
apiHost,
|
apiHost,
|
||||||
apiUrl: `https://${apiHost}`,
|
apiUrl: portlessHttpsUrl(apiHost),
|
||||||
viteHost,
|
viteHost,
|
||||||
viteUrl: `https://${viteHost}`,
|
viteUrl: portlessHttpsUrl(viteHost),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function portlessHttpsUrl(host: string): string {
|
||||||
|
const port = currentPortlessProxyPort();
|
||||||
|
const suffix = port && port !== 443 ? `:${port}` : "";
|
||||||
|
return `https://${host}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentPortlessProxyPort(): number | undefined {
|
||||||
|
const envPort = Number(process.env.PORTLESS_PORT);
|
||||||
|
if (Number.isInteger(envPort) && envPort > 0) return envPort;
|
||||||
|
if (!existsSync(PORTLESS_PROXY_PORT_FILE)) return undefined;
|
||||||
|
|
||||||
|
const filePort = Number(
|
||||||
|
readFileSync(PORTLESS_PROXY_PORT_FILE, "utf-8").trim(),
|
||||||
|
);
|
||||||
|
if (Number.isInteger(filePort) && filePort > 0) return filePort;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function killOwnPorts(worktreeNum: number): void {
|
export function killOwnPorts(worktreeNum: number): void {
|
||||||
const offset = (worktreeNum - 1) * 100;
|
const offset = (worktreeNum - 1) * 100;
|
||||||
const ports = [8080 + offset, 3000 + offset, 3001 + offset];
|
const ports = [8080 + offset, 3000 + offset, 3001 + offset];
|
||||||
if (process.platform === "win32") return;
|
if (process.platform === "win32") return;
|
||||||
const lsof = sh("lsof", ports.flatMap((p) => ["-ti", `:${p}`]));
|
const lsof = sh(
|
||||||
|
"lsof",
|
||||||
|
ports.flatMap((p) => ["-ti", `:${p}`]),
|
||||||
|
);
|
||||||
const pids = lsof.stdout.split("\n").filter(Boolean);
|
const pids = lsof.stdout.split("\n").filter(Boolean);
|
||||||
for (const pid of pids) {
|
for (const pid of pids) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { log, fatal } from "./shell.ts";
|
import { join } from "node:path";
|
||||||
import { registerPortlessAliases } from "./portless.ts";
|
|
||||||
import { rewriteDbEnv } from "./url.ts";
|
|
||||||
import { aliasesFor, killOwnPorts } from "./ports.ts";
|
|
||||||
import { tmuxSessionName, spawnDevInTmux } from "./tmux.ts";
|
|
||||||
import { PROJECT_ROOT } from "../constants.ts";
|
import { PROJECT_ROOT } from "../constants.ts";
|
||||||
import type { RegistryEntry } from "../types.ts";
|
import type { RegistryEntry } from "../types.ts";
|
||||||
|
import { registerPortlessAliases } from "./portless.ts";
|
||||||
|
import { portlessHttpsUrl } from "./ports.ts";
|
||||||
|
import { fatal, log } from "./shell.ts";
|
||||||
|
import { spawnDevInTmux, tmuxSessionName } from "./tmux.ts";
|
||||||
|
import { rewriteDbEnv } from "./url.ts";
|
||||||
|
|
||||||
export function buildDevEnvAndArgs(entry: RegistryEntry): {
|
export function buildDevEnvAndArgs(entry: RegistryEntry): {
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
@@ -21,7 +21,7 @@ export function buildDevEnvAndArgs(entry: RegistryEntry): {
|
|||||||
if (!databaseUrl) fatal("agent worktree missing databaseUrl");
|
if (!databaseUrl) fatal("agent worktree missing databaseUrl");
|
||||||
env = rewriteDbEnv(env, databaseUrl);
|
env = rewriteDbEnv(env, databaseUrl);
|
||||||
if (!env.EMULATE_GOOGLE_URL) {
|
if (!env.EMULATE_GOOGLE_URL) {
|
||||||
env.EMULATE_GOOGLE_URL = "https://google.emulate.localhost";
|
env.EMULATE_GOOGLE_URL = portlessHttpsUrl("google.emulate.localhost");
|
||||||
}
|
}
|
||||||
const portlessCa = join(homedir(), ".portless", "ca.pem");
|
const portlessCa = join(homedir(), ".portless", "ca.pem");
|
||||||
if (existsSync(portlessCa) && !env.NODE_EXTRA_CA_CERTS) {
|
if (existsSync(portlessCa) && !env.NODE_EXTRA_CA_CERTS) {
|
||||||
@@ -50,14 +50,18 @@ export function buildDevEnvAndArgs(entry: RegistryEntry): {
|
|||||||
return { env, args };
|
return { env, args };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startDev(entry: RegistryEntry, opts?: { allowTmux?: boolean }): never {
|
export function startDev(
|
||||||
|
entry: RegistryEntry,
|
||||||
|
opts?: { allowTmux?: boolean },
|
||||||
|
): never {
|
||||||
const { worktreeNum, branchName } = entry;
|
const { worktreeNum, branchName } = entry;
|
||||||
const { env, args } = buildDevEnvAndArgs(entry);
|
const { env, args } = buildDevEnvAndArgs(entry);
|
||||||
|
|
||||||
// Agent worktrees (N > 1) in a non-TTY invocation: wrap in detached tmux
|
// Agent worktrees (N > 1) in a non-TTY invocation: wrap in detached tmux
|
||||||
// so the calling agent doesn't block. Canonical (N=1) stays inline always.
|
// so the calling agent doesn't block. Canonical (N=1) stays inline always.
|
||||||
// Node/Bun sets isTTY to true when stdout is a TTY and undefined otherwise.
|
// Node/Bun sets isTTY to true when stdout is a TTY and undefined otherwise.
|
||||||
const useTmux = (opts?.allowTmux ?? true) && worktreeNum > 1 && !process.stdout.isTTY;
|
const useTmux =
|
||||||
|
(opts?.allowTmux ?? true) && worktreeNum > 1 && !process.stdout.isTTY;
|
||||||
if (useTmux) {
|
if (useTmux) {
|
||||||
log(
|
log(
|
||||||
`starting dev in tmux (worktree=${worktreeNum}${branchName ? `, branch=${branchName}` : ""}, non-TTY)`,
|
`starting dev in tmux (worktree=${worktreeNum}${branchName ? `, branch=${branchName}` : ""}, non-TTY)`,
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ import inquirer from "inquirer";
|
|||||||
|
|
||||||
loadLocalEnv();
|
loadLocalEnv();
|
||||||
|
|
||||||
|
// Dev worktrees (scripts/dw): overlay server/.env.local -- the same override
|
||||||
|
// `bun dw run` gets via Bun's automatic .env.local loading -- so functions
|
||||||
|
// land on the worktree's Neon branch instead of the canonical dev DB. Never
|
||||||
|
// applied for prod targets (migrate-functions:prod): infisical injects the
|
||||||
|
// prod DATABASE_URL before this script starts, and prod URLs carry the
|
||||||
|
// us-east-2 marker (same convention as assertNotProductionDb).
|
||||||
|
if (!process.env.DATABASE_URL?.includes("us-east-2")) {
|
||||||
|
process.env.ENV_FILE = ".env.local";
|
||||||
|
loadLocalEnv({ force: true });
|
||||||
|
}
|
||||||
|
|
||||||
export const migrateFunctions = async () => {
|
export const migrateFunctions = async () => {
|
||||||
// Dynamic import to ensure env is loaded first
|
// Dynamic import to ensure env is loaded first
|
||||||
const { initializeDatabaseFunctions } = await import(
|
const { initializeDatabaseFunctions } = await import(
|
||||||
|
|||||||
@@ -15,17 +15,17 @@
|
|||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"ink": "^5.1.0",
|
"ink": "^6.6.0",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
"inquirer": "^12.6.3",
|
"inquirer": "^12.6.3",
|
||||||
"p-limit": "^7.2.0",
|
"p-limit": "^7.2.0",
|
||||||
"pg": "8.20.0",
|
"pg": "8.20.0",
|
||||||
"react": "^18.3.1"
|
"react": "^19.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.11",
|
"@types/bun": "^1.3.11",
|
||||||
"@types/pg": "8.20.0",
|
"@types/pg": "8.20.0",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^19.2.1",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,23 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|||||||
SEED="$ROOT/emulate.config.yaml"
|
SEED="$ROOT/emulate.config.yaml"
|
||||||
LOG="$HOME/.autumn-emulate.log"
|
LOG="$HOME/.autumn-emulate.log"
|
||||||
PID_FILE="$HOME/.autumn-emulate.pid"
|
PID_FILE="$HOME/.autumn-emulate.pid"
|
||||||
|
PORTLESS_PORT_FILE="$HOME/.portless/proxy.port"
|
||||||
|
EMULATE_URL="https://google.emulate.localhost"
|
||||||
|
|
||||||
|
PORTLESS_PROXY_PORT="${PORTLESS_PORT:-}"
|
||||||
|
if [[ -z "$PORTLESS_PROXY_PORT" && -f "$PORTLESS_PORT_FILE" ]]; then
|
||||||
|
PORTLESS_PROXY_PORT="$(cat "$PORTLESS_PORT_FILE" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [[ -n "$PORTLESS_PROXY_PORT" && "$PORTLESS_PROXY_PORT" != "443" ]]; then
|
||||||
|
EMULATE_URL="${EMULATE_URL}:${PORTLESS_PROXY_PORT}"
|
||||||
|
fi
|
||||||
|
|
||||||
reachable() {
|
reachable() {
|
||||||
curl -sf -o /dev/null --max-time 1 "https://google.emulate.localhost/.well-known/openid-configuration"
|
curl -sf -o /dev/null --max-time 1 "${EMULATE_URL}/.well-known/openid-configuration"
|
||||||
}
|
}
|
||||||
|
|
||||||
if reachable; then
|
if reachable; then
|
||||||
echo "[emulate] already reachable at https://google.emulate.localhost"
|
echo "[emulate] already reachable at ${EMULATE_URL}"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -54,7 +64,7 @@ disown
|
|||||||
# Block briefly until the emulator is actually serving so callers can race.
|
# Block briefly until the emulator is actually serving so callers can race.
|
||||||
for _ in $(seq 1 30); do
|
for _ in $(seq 1 30); do
|
||||||
if reachable; then
|
if reachable; then
|
||||||
echo "[emulate] ready at https://google.emulate.localhost (pid $(cat "$PID_FILE"))"
|
echo "[emulate] ready at ${EMULATE_URL} (pid $(cat "$PID_FILE"))"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
sleep 0.3
|
sleep 0.3
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ const generateNormalized = (): NormalizedFullSubject => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rollovers: [] as any,
|
rollovers: [] as any,
|
||||||
|
usage_windows: [] as any,
|
||||||
replaceables: [] as any,
|
replaceables: [] as any,
|
||||||
customerPrice: null as any,
|
customerPrice: null as any,
|
||||||
customerProductOptions: [] as any,
|
customerProductOptions: [] as any,
|
||||||
|
|||||||
@@ -159,8 +159,8 @@
|
|||||||
"@types/mocha": "^10.0.10",
|
"@types/mocha": "^10.0.10",
|
||||||
"@types/node": "^25.0.7",
|
"@types/node": "^25.0.7",
|
||||||
"@types/pg": "8.20.0",
|
"@types/pg": "8.20.0",
|
||||||
"@types/react": "18.3.28",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "18.3.7",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"artillery": "^2.0.30",
|
"artillery": "^2.0.30",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
--[[
|
||||||
|
Lua Script: Roll usage-window counters in a per-feature hash
|
||||||
|
|
||||||
|
Atomically patches rows in the reserved '_usage_windows' field: the lazy
|
||||||
|
roll (post-getFullSubject) zeroes counts whose window closed and advances
|
||||||
|
bounds/anchor to the current derivation. Atomicity matters because a
|
||||||
|
concurrent deduction may be writing the same field.
|
||||||
|
|
||||||
|
Fail-open: a missing/malformed field, or a row absent for a scope, is left
|
||||||
|
untouched (the write path creates rows; the roll only maintains them).
|
||||||
|
|
||||||
|
KEYS[1] = balance hash key
|
||||||
|
ARGV[1] = JSON params:
|
||||||
|
{
|
||||||
|
now: number,
|
||||||
|
ttl_seconds: number,
|
||||||
|
rolls: [{
|
||||||
|
internal_entity_id: string | null, -- scope selector
|
||||||
|
zero_usage: boolean, -- stored window closed: count dies
|
||||||
|
window_start_at: number,
|
||||||
|
window_end_at: number,
|
||||||
|
anchor_customer_entitlement_id: string | null,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns JSON: { rolled: number }
|
||||||
|
]]
|
||||||
|
|
||||||
|
local params = cjson.decode(ARGV[1])
|
||||||
|
local now = safe_number(params.now)
|
||||||
|
local ttl_seconds = safe_number(params.ttl_seconds)
|
||||||
|
|
||||||
|
local USAGE_WINDOWS_FIELD = '_usage_windows'
|
||||||
|
|
||||||
|
local raw = redis.call('HGET', KEYS[1], USAGE_WINDOWS_FIELD)
|
||||||
|
if is_nil(raw) then
|
||||||
|
return cjson.encode({ rolled = 0 })
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, windows = pcall(cjson.decode, raw)
|
||||||
|
if not ok or type(windows) ~= 'table' then
|
||||||
|
return cjson.encode({ rolled = 0 })
|
||||||
|
end
|
||||||
|
|
||||||
|
local rolled = 0
|
||||||
|
for _, roll in ipairs(params.rolls or {}) do
|
||||||
|
local roll_entity = roll.internal_entity_id
|
||||||
|
for _, window in ipairs(windows) do
|
||||||
|
if type(window) == 'table' then
|
||||||
|
local window_entity = window.internal_entity_id
|
||||||
|
local entities_match =
|
||||||
|
(is_nil(roll_entity) and is_nil(window_entity))
|
||||||
|
or roll_entity == window_entity
|
||||||
|
if entities_match then
|
||||||
|
if roll.zero_usage then
|
||||||
|
window.usage = 0
|
||||||
|
end
|
||||||
|
window.window_start_at = roll.window_start_at
|
||||||
|
window.window_end_at = roll.window_end_at
|
||||||
|
window.anchor_customer_entitlement_id =
|
||||||
|
roll.anchor_customer_entitlement_id
|
||||||
|
window.updated_at = now
|
||||||
|
rolled = rolled + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if rolled == 0 then
|
||||||
|
return cjson.encode({ rolled = 0 })
|
||||||
|
end
|
||||||
|
|
||||||
|
local encoded = #windows > 0 and cjson.encode(windows) or '[]'
|
||||||
|
redis.call('HSET', KEYS[1], USAGE_WINDOWS_FIELD, encoded)
|
||||||
|
|
||||||
|
if ttl_seconds > 0 and redis.call('TTL', KEYS[1]) < 0 then
|
||||||
|
redis.call('EXPIRE', KEYS[1], ttl_seconds)
|
||||||
|
end
|
||||||
|
|
||||||
|
return cjson.encode({ rolled = rolled })
|
||||||
@@ -30,10 +30,18 @@ local function init_context(params)
|
|||||||
local context = {
|
local context = {
|
||||||
customer_entitlements = {},
|
customer_entitlements = {},
|
||||||
rollovers = {},
|
rollovers = {},
|
||||||
|
-- Customer-scoped windowed-cap counters, loaded below alongside the other
|
||||||
|
-- subject state: { [feature_id] = { balance_key, windows, dirty } }.
|
||||||
|
usage_windows = read_usage_windows({
|
||||||
|
usage_window_limits = params.usage_window_limits,
|
||||||
|
balance_keys_by_feature_id = params.balance_keys_by_feature_id,
|
||||||
|
now = params.usage_window_now,
|
||||||
|
}),
|
||||||
org_id = params.org_id,
|
org_id = params.org_id,
|
||||||
env = params.env,
|
env = params.env,
|
||||||
customer_id = params.customer_id,
|
customer_id = params.customer_id,
|
||||||
mutation_logs = {},
|
mutation_logs = {},
|
||||||
|
usage_window_mutations = {},
|
||||||
pending_writes = {},
|
pending_writes = {},
|
||||||
pending_write_ids = {},
|
pending_write_ids = {},
|
||||||
missing_customer_entitlement_ids =
|
missing_customer_entitlement_ids =
|
||||||
|
|||||||
@@ -48,11 +48,30 @@
|
|||||||
idempotency_ttl_ms: number | null
|
idempotency_ttl_ms: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Usage windows (customer-scoped windowed caps):
|
||||||
|
CONFIG IN: params.usage_window_limits[] -- the resolved caps (limit,
|
||||||
|
bounds, dimension) from fullSubjectToUsageWindowLimits.
|
||||||
|
COUNTERS OUT: usage_windows_by_feature_id -- the post-deduction COUNTER
|
||||||
|
ROWS (DbUsageWindow: usage amounts, mirrors the
|
||||||
|
usage_windows table), NOT the config.
|
||||||
|
Counters live in the capped feature's balance hash under the reserved
|
||||||
|
'_usage_windows' field (so each capped feature's hash key must be in
|
||||||
|
KEYS[], via usageWindowFeatureIds in the TS key builder); they are loaded
|
||||||
|
into context.usage_windows by init_context and follow the same in-memory
|
||||||
|
mutate -> flush lifecycle as entitlement balances. Enforcement is woven
|
||||||
|
into the deduction passes like spend limits: each ent's deductible amount
|
||||||
|
is gated by window headroom (with credit conversions), and a window-capped
|
||||||
|
leftover flows through the standard overage_behaviour handling ('cap'
|
||||||
|
applies the partial deduction, 'reject' returns INSUFFICIENT_BALANCE). A
|
||||||
|
missing field loads as an empty counter set (fail open).
|
||||||
|
|
||||||
Returns JSON:
|
Returns JSON:
|
||||||
{
|
{
|
||||||
updates: { [cus_ent_id]: { balance, additional_balance, adjustment, entities, deducted, additional_deducted } },
|
updates: { [cus_ent_id]: { balance, additional_balance, adjustment, entities, deducted, additional_deducted } },
|
||||||
rollover_updates: { [rollover_id]: { balance, usage, entities } },
|
rollover_updates: { [rollover_id]: { balance, usage, entities } },
|
||||||
modified_customer_entitlement_ids: string[],
|
modified_customer_entitlement_ids: string[],
|
||||||
|
usage_windows_by_feature_id: { [feature_id]: DbUsageWindow[] } | null,
|
||||||
|
usage_window_mutations: { usage_window_id, feature_id, internal_entity_id, window_start_at, usage_delta }[],
|
||||||
remaining: number,
|
remaining: number,
|
||||||
error: string | null,
|
error: string | null,
|
||||||
feature_id: string | null
|
feature_id: string | null
|
||||||
@@ -111,6 +130,10 @@ local idempotency_ttl_ms = params.idempotency_ttl_ms
|
|||||||
local lock = params.lock
|
local lock = params.lock
|
||||||
local unwind_value = params.unwind_value
|
local unwind_value = params.unwind_value
|
||||||
local lock_receipt_key = lock_receipt_key_from_keys
|
local lock_receipt_key = lock_receipt_key_from_keys
|
||||||
|
local usage_window_limits = params.usage_window_limits
|
||||||
|
local usage_window_now = params.usage_window_now
|
||||||
|
local usage_window_ttl_seconds = params.usage_window_ttl_seconds
|
||||||
|
local is_consumption = params.is_consumption
|
||||||
|
|
||||||
if not is_nil(idempotency_key) then
|
if not is_nil(idempotency_key) then
|
||||||
if redis.call('EXISTS', idempotency_key) == 1 then
|
if redis.call('EXISTS', idempotency_key) == 1 then
|
||||||
@@ -139,11 +162,30 @@ if #customer_entitlement_deductions == 0 then
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Usage windows are enforced for positive consumption INCLUDING locks (a
|
||||||
|
-- lock reserves headroom and counts at lock time), never for refunds,
|
||||||
|
-- target_balance, or granted-balance edits. Unwinds don't enforce but DO
|
||||||
|
-- load counters so the freed amount can be decremented back. Computed before
|
||||||
|
-- init_context so non-participating calls skip the counter reads entirely.
|
||||||
|
local has_usage_window_limits = not is_nil(usage_window_limits)
|
||||||
|
and #usage_window_limits > 0
|
||||||
|
-- A zero unwind_value (finalize at-or-above the lock) is no unwind at all:
|
||||||
|
-- the extra delta must still be enforced and counted.
|
||||||
|
local has_unwind = not is_nil(unwind_value) and safe_number(unwind_value) > 0
|
||||||
|
local enforce_usage_windows = is_consumption
|
||||||
|
and not has_unwind
|
||||||
|
and has_usage_window_limits
|
||||||
|
local unwind_usage_windows = has_unwind and has_usage_window_limits
|
||||||
|
|
||||||
local context = init_context({
|
local context = init_context({
|
||||||
org_id = org_id,
|
org_id = org_id,
|
||||||
env = env,
|
env = env,
|
||||||
customer_id = customer_id,
|
customer_id = customer_id,
|
||||||
customer_entitlement_deductions = customer_entitlement_deductions,
|
customer_entitlement_deductions = customer_entitlement_deductions,
|
||||||
|
usage_window_limits = (enforce_usage_windows or unwind_usage_windows)
|
||||||
|
and usage_window_limits
|
||||||
|
or nil,
|
||||||
|
usage_window_now = usage_window_now,
|
||||||
balance_keys_by_feature_id = params.balance_keys_by_feature_id,
|
balance_keys_by_feature_id = params.balance_keys_by_feature_id,
|
||||||
debug = params.debug,
|
debug = params.debug,
|
||||||
})
|
})
|
||||||
@@ -185,6 +227,14 @@ if not is_nil(unwind_value) and safe_number(unwind_value) > 0 then
|
|||||||
-- Track which entitlements the unwind touched so the caller can sync them.
|
-- Track which entitlements the unwind touched so the caller can sync them.
|
||||||
unwind_modified_cus_ent_ids = unwind_result.modified_customer_entitlement_ids or {}
|
unwind_modified_cus_ent_ids = unwind_result.modified_customer_entitlement_ids or {}
|
||||||
|
|
||||||
|
if unwind_usage_windows then
|
||||||
|
decrement_usage_windows_for_unwind({
|
||||||
|
context = context,
|
||||||
|
iterations = unwind_result.iterations,
|
||||||
|
now = usage_window_now,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
-- Fold any skipped unwind (missing entitlements/rollovers) into amount_to_deduct
|
-- Fold any skipped unwind (missing entitlements/rollovers) into amount_to_deduct
|
||||||
-- so the forward pass compensates against current live entitlements.
|
-- so the forward pass compensates against current live entitlements.
|
||||||
local skipped = unwind_result.remaining_signed_unwind_value or 0
|
local skipped = unwind_result.remaining_signed_unwind_value or 0
|
||||||
@@ -194,6 +244,7 @@ if not is_nil(unwind_value) and safe_number(unwind_value) > 0 then
|
|||||||
end
|
end
|
||||||
|
|
||||||
local logger = context.logger
|
local logger = context.logger
|
||||||
|
|
||||||
logger.log("=== LUA DEDUCTION START ===")
|
logger.log("=== LUA DEDUCTION START ===")
|
||||||
logger.log("=== PARAMS ===")
|
logger.log("=== PARAMS ===")
|
||||||
logger.log(" amount_to_deduct: %s", tostring(amount_to_deduct or "nil"))
|
logger.log(" amount_to_deduct: %s", tostring(amount_to_deduct or "nil"))
|
||||||
@@ -235,18 +286,21 @@ for _, cus_ent_id in ipairs(unwind_modified_cus_ent_ids) do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local modified_customer_entitlement_ids = collect_modified_customer_entitlement_ids({
|
|
||||||
context = context,
|
|
||||||
extra_customer_entitlement_ids = unwind_modified_cus_ent_ids,
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.log(" remaining_amount: %s", tostring(remaining_amount or "nil"))
|
logger.log(" remaining_amount: %s", tostring(remaining_amount or "nil"))
|
||||||
logger.log(" is_refund: %s", tostring(remaining_amount < 0 or false))
|
logger.log(" is_refund: %s", tostring(remaining_amount < 0 or false))
|
||||||
local mutation_logs = context.mutation_logs
|
local mutation_logs = context.mutation_logs
|
||||||
if type(mutation_logs) ~= 'table' or #mutation_logs == 0 then
|
if type(mutation_logs) ~= 'table' or #mutation_logs == 0 then
|
||||||
mutation_logs = cjson.decode('[]')
|
mutation_logs = cjson.decode('[]')
|
||||||
end
|
end
|
||||||
-- Throw error and don't apply updates if we're in reject mode and there's still remaining amount
|
local usage_window_mutations = context.usage_window_mutations
|
||||||
|
if type(usage_window_mutations) ~= 'table' or #usage_window_mutations == 0 then
|
||||||
|
usage_window_mutations = cjson.decode('[]')
|
||||||
|
end
|
||||||
|
-- Throw error and don't apply updates if we're in reject mode and there's
|
||||||
|
-- still remaining amount. Usage-window shortfalls flow through here like any
|
||||||
|
-- other: the deduction passes already gated every ent by window headroom, so
|
||||||
|
-- a window-capped leftover clamps under 'cap' and rejects as
|
||||||
|
-- INSUFFICIENT_BALANCE under 'reject'.
|
||||||
if remaining_amount > 0 and overage_behaviour == 'reject' then
|
if remaining_amount > 0 and overage_behaviour == 'reject' then
|
||||||
return cjson.encode({
|
return cjson.encode({
|
||||||
error = 'INSUFFICIENT_BALANCE',
|
error = 'INSUFFICIENT_BALANCE',
|
||||||
@@ -259,6 +313,19 @@ if remaining_amount > 0 and overage_behaviour == 'reject' then
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if enforce_usage_windows then
|
||||||
|
increment_usage_window_counters({
|
||||||
|
context = context,
|
||||||
|
usage_window_limits = usage_window_limits,
|
||||||
|
now = usage_window_now,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local modified_customer_entitlement_ids = collect_modified_customer_entitlement_ids({
|
||||||
|
context = context,
|
||||||
|
extra_customer_entitlement_ids = unwind_modified_cus_ent_ids,
|
||||||
|
})
|
||||||
|
|
||||||
if not is_nil(lock)
|
if not is_nil(lock)
|
||||||
and not is_nil(lock.enabled)
|
and not is_nil(lock.enabled)
|
||||||
and lock.enabled
|
and lock.enabled
|
||||||
@@ -308,6 +375,10 @@ update_aggregated_balances({
|
|||||||
mutation_logs = mutation_logs,
|
mutation_logs = mutation_logs,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if enforce_usage_windows or unwind_usage_windows then
|
||||||
|
apply_usage_window_writes(context, usage_window_ttl_seconds)
|
||||||
|
end
|
||||||
|
|
||||||
if not is_nil(idempotency_key) and not is_nil(idempotency_ttl_ms) then
|
if not is_nil(idempotency_key) and not is_nil(idempotency_ttl_ms) then
|
||||||
redis.call('SET', idempotency_key, '1', 'PX', idempotency_ttl_ms)
|
redis.call('SET', idempotency_key, '1', 'PX', idempotency_ttl_ms)
|
||||||
end
|
end
|
||||||
@@ -319,6 +390,9 @@ return cjson.encode({
|
|||||||
rollover_updates = rollover_updates,
|
rollover_updates = rollover_updates,
|
||||||
modified_customer_entitlement_ids = modified_customer_entitlement_ids,
|
modified_customer_entitlement_ids = modified_customer_entitlement_ids,
|
||||||
mutation_logs = mutation_logs,
|
mutation_logs = mutation_logs,
|
||||||
|
usage_windows_by_feature_id =
|
||||||
|
usage_windows_to_result(context) or cjson.null,
|
||||||
|
usage_window_mutations = usage_window_mutations,
|
||||||
remaining = remaining_amount,
|
remaining = remaining_amount,
|
||||||
error = cjson.null,
|
error = cjson.null,
|
||||||
logs = context.logs
|
logs = context.logs
|
||||||
|
|||||||
@@ -442,5 +442,8 @@ local function unwind_lock_on_context(params)
|
|||||||
modified_customer_entitlement_ids = modified_ids.modified_customer_entitlement_ids,
|
modified_customer_entitlement_ids = modified_ids.modified_customer_entitlement_ids,
|
||||||
modified_rollover_ids = modified_ids.modified_rollover_ids,
|
modified_rollover_ids = modified_ids.modified_rollover_ids,
|
||||||
mutation_logs = context.mutation_logs,
|
mutation_logs = context.mutation_logs,
|
||||||
|
-- Per-item applied amounts (tracked units + credit_cost), so callers can
|
||||||
|
-- mirror the unwind onto usage-window counters.
|
||||||
|
iterations = unwind_items_result.iterations,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,28 +18,45 @@ local function read_subject_balances(params)
|
|||||||
local balances_by_id = {}
|
local balances_by_id = {}
|
||||||
local missing_customer_entitlement_ids = {}
|
local missing_customer_entitlement_ids = {}
|
||||||
local entries_by_balance_key = {}
|
local entries_by_balance_key = {}
|
||||||
|
local seen_ids_by_balance_key = {}
|
||||||
local balance_keys_by_feature_id = safe_table(params.balance_keys_by_feature_id)
|
local balance_keys_by_feature_id = safe_table(params.balance_keys_by_feature_id)
|
||||||
|
|
||||||
|
local function queue_balance_read(customer_entitlement_id, feature_id)
|
||||||
|
if not (customer_entitlement_id and feature_id) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local balance_key = balance_keys_by_feature_id[feature_id]
|
||||||
|
if not balance_key then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
if entries_by_balance_key[balance_key] == nil then
|
||||||
|
entries_by_balance_key[balance_key] = {
|
||||||
|
feature_id = feature_id,
|
||||||
|
customer_entitlement_ids = {},
|
||||||
|
}
|
||||||
|
seen_ids_by_balance_key[balance_key] = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
if seen_ids_by_balance_key[balance_key][customer_entitlement_id] then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
seen_ids_by_balance_key[balance_key][customer_entitlement_id] = true
|
||||||
|
|
||||||
|
table.insert(
|
||||||
|
entries_by_balance_key[balance_key].customer_entitlement_ids,
|
||||||
|
customer_entitlement_id
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
for _, ent_obj in ipairs(params.customer_entitlement_deductions or {}) do
|
for _, ent_obj in ipairs(params.customer_entitlement_deductions or {}) do
|
||||||
local customer_entitlement_id = ent_obj.customer_entitlement_id
|
local customer_entitlement_id = ent_obj.customer_entitlement_id
|
||||||
local feature_id = ent_obj.feature_id
|
if customer_entitlement_id then
|
||||||
|
local queued = queue_balance_read(customer_entitlement_id, ent_obj.feature_id)
|
||||||
if customer_entitlement_id and feature_id then
|
if not queued then
|
||||||
local balance_key = balance_keys_by_feature_id[feature_id]
|
|
||||||
if not balance_key then
|
|
||||||
table.insert(missing_customer_entitlement_ids, customer_entitlement_id)
|
table.insert(missing_customer_entitlement_ids, customer_entitlement_id)
|
||||||
else
|
|
||||||
if entries_by_balance_key[balance_key] == nil then
|
|
||||||
entries_by_balance_key[balance_key] = {
|
|
||||||
feature_id = feature_id,
|
|
||||||
customer_entitlement_ids = {},
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
table.insert(
|
|
||||||
entries_by_balance_key[balance_key].customer_entitlement_ids,
|
|
||||||
customer_entitlement_id
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ local function process_deduction_pass(params)
|
|||||||
local ent_id = ent_obj.customer_entitlement_id
|
local ent_id = ent_obj.customer_entitlement_id
|
||||||
local credit_cost = ent_obj.credit_cost
|
local credit_cost = ent_obj.credit_cost
|
||||||
local ent_feature_id = ent_obj.feature_id
|
local ent_feature_id = ent_obj.feature_id
|
||||||
if credit_cost == cjson.null or credit_cost == nil then
|
if credit_cost == cjson.null or credit_cost == nil or credit_cost == 0 then
|
||||||
credit_cost = 1
|
credit_cost = 1
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -85,23 +85,43 @@ local function process_deduction_pass(params)
|
|||||||
usage_allowed = usage_allowed or overage_behavior_is_allow
|
usage_allowed = usage_allowed or overage_behavior_is_allow
|
||||||
|
|
||||||
local should_process = not skip_if_not_usage_allowed or usage_allowed
|
local should_process = not skip_if_not_usage_allowed or usage_allowed
|
||||||
|
local skip_reason = "usage_allowed=false"
|
||||||
if not context.customer_entitlements[ent_id] then
|
if not context.customer_entitlements[ent_id] then
|
||||||
should_process = false
|
should_process = false
|
||||||
|
skip_reason = "not in context"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Usage-window gate, mirroring the spend-limit overage gate above: cap
|
||||||
|
-- this ent's deductible amount by the remaining window headroom (metered
|
||||||
|
-- limits cap every ent in tracked units; balance limits cap ents of the
|
||||||
|
-- capped feature, converted via THIS ent's credit_cost). A fully blocked
|
||||||
|
-- ent is skipped rather than breaking the loop -- a balance-dim cap only
|
||||||
|
-- binds its own feature's pools, so other ents may be unconstrained.
|
||||||
|
local ent_amount = remaining_amount
|
||||||
|
if should_process and remaining_amount > 0 then
|
||||||
|
local available_from_usage_windows = get_available_from_usage_windows({
|
||||||
|
context = context,
|
||||||
|
ent_feature_id = ent_feature_id,
|
||||||
|
credit_cost = credit_cost,
|
||||||
|
})
|
||||||
|
if not is_nil(available_from_usage_windows)
|
||||||
|
and available_from_usage_windows < ent_amount then
|
||||||
|
ent_amount = available_from_usage_windows
|
||||||
|
end
|
||||||
|
if ent_amount == 0 then
|
||||||
|
should_process = false
|
||||||
|
skip_reason = "usage window headroom exhausted"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if not should_process then
|
if not should_process then
|
||||||
logger.log("%s skipping %s - usage_allowed=false or not in context", pass_name, ent_id)
|
logger.log("%s skipping %s - %s", pass_name, ent_id, skip_reason)
|
||||||
elseif credit_cost == 0 then
|
|
||||||
-- Zero credit cost (e.g. -100% markup AI model): the usage is free.
|
|
||||||
-- Consume the requested amount without touching any balance.
|
|
||||||
logger.log("%s ent %s credit_cost=0 - free deduction, no balance change", pass_name, ent_id)
|
|
||||||
remaining_amount = 0
|
|
||||||
else
|
else
|
||||||
local deducted = deduct_from_main_balance({
|
local deducted = deduct_from_main_balance({
|
||||||
context = context,
|
context = context,
|
||||||
ent_id = ent_id,
|
ent_id = ent_id,
|
||||||
target_entity_id = target_entity_id,
|
target_entity_id = target_entity_id,
|
||||||
amount = remaining_amount,
|
amount = ent_amount,
|
||||||
credit_cost = credit_cost,
|
credit_cost = credit_cost,
|
||||||
pass_number = pass_number,
|
pass_number = pass_number,
|
||||||
available_overage = available_overage,
|
available_overage = available_overage,
|
||||||
@@ -112,7 +132,17 @@ local function process_deduction_pass(params)
|
|||||||
log_prefix = pass_name,
|
log_prefix = pass_name,
|
||||||
})
|
})
|
||||||
|
|
||||||
remaining_amount = remaining_amount - (deducted / credit_cost)
|
local deducted_units = deducted / credit_cost
|
||||||
|
remaining_amount = remaining_amount - deducted_units
|
||||||
|
|
||||||
|
-- Settle the gate: record what this ent actually drained against every
|
||||||
|
-- applicable window limit so the next ent sees the reduced headroom.
|
||||||
|
consume_usage_window_headroom({
|
||||||
|
context = context,
|
||||||
|
ent_feature_id = ent_feature_id,
|
||||||
|
credit_cost = credit_cost,
|
||||||
|
units = deducted_units,
|
||||||
|
})
|
||||||
|
|
||||||
if deducted ~= 0 then
|
if deducted ~= 0 then
|
||||||
if not updates[ent_id] then
|
if not updates[ent_id] then
|
||||||
@@ -151,6 +181,26 @@ local function process_rollover_deduction(params)
|
|||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Metered window limits count tracked units regardless of funding source,
|
||||||
|
-- so they gate the rollover phase too. Balance limits do not (ent_feature_id
|
||||||
|
-- = nil): rollover drains stay outside credit-pool caps, matching how spend
|
||||||
|
-- limits ignore them.
|
||||||
|
local rollover_amount = remaining_amount
|
||||||
|
local available_from_usage_windows = get_available_from_usage_windows({
|
||||||
|
context = context,
|
||||||
|
ent_feature_id = nil,
|
||||||
|
credit_cost = 1,
|
||||||
|
})
|
||||||
|
if not is_nil(available_from_usage_windows)
|
||||||
|
and available_from_usage_windows < rollover_amount then
|
||||||
|
rollover_amount = available_from_usage_windows
|
||||||
|
end
|
||||||
|
|
||||||
|
if rollover_amount <= 0 then
|
||||||
|
logger.log("Rollover deduction skipped - usage window headroom exhausted")
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
local first_ent = customer_entitlement_deductions[1]
|
local first_ent = customer_entitlement_deductions[1]
|
||||||
local has_entity_scope = false
|
local has_entity_scope = false
|
||||||
if first_ent then
|
if first_ent then
|
||||||
@@ -160,11 +210,18 @@ local function process_rollover_deduction(params)
|
|||||||
local rollover_deducted = deduct_from_rollovers({
|
local rollover_deducted = deduct_from_rollovers({
|
||||||
context = context,
|
context = context,
|
||||||
rollovers = rollovers,
|
rollovers = rollovers,
|
||||||
amount = remaining_amount,
|
amount = rollover_amount,
|
||||||
target_entity_id = target_entity_id,
|
target_entity_id = target_entity_id,
|
||||||
has_entity_scope = has_entity_scope,
|
has_entity_scope = has_entity_scope,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
consume_usage_window_headroom({
|
||||||
|
context = context,
|
||||||
|
ent_feature_id = nil,
|
||||||
|
credit_cost = 1,
|
||||||
|
units = rollover_deducted,
|
||||||
|
})
|
||||||
|
|
||||||
logger.log("Rollover deduction: deducted=%s, remaining=%s", rollover_deducted, remaining_amount - rollover_deducted)
|
logger.log("Rollover deduction: deducted=%s, remaining=%s", rollover_deducted, remaining_amount - rollover_deducted)
|
||||||
|
|
||||||
return rollover_deducted
|
return rollover_deducted
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- READ USAGE WINDOWS
|
||||||
|
-- Loads customer-scoped usage-window counter state into the deduction context
|
||||||
|
-- (sibling of read_subject_balances). Counters live in the capped feature's
|
||||||
|
-- balance hash under the reserved '_usage_windows' field, as a lean ARRAY of
|
||||||
|
-- rows mirroring the usage_windows table (DbUsageWindow):
|
||||||
|
-- { id, internal_customer_id, internal_entity_id, feature_id,
|
||||||
|
-- internal_feature_id, anchor_customer_entitlement_id,
|
||||||
|
-- window_start_at, window_end_at, usage, updated_at }
|
||||||
|
--
|
||||||
|
-- Each loaded entry also carries the deduction-time runtime state the per-ent
|
||||||
|
-- gate consumes: the resolved limit, its dimension, the remaining `headroom`
|
||||||
|
-- (limit - current window usage, decremented as the deduction passes drain
|
||||||
|
-- it), and `consumed` (this operation's total, in the limit's native unit).
|
||||||
|
--
|
||||||
|
-- FAIL OPEN: a missing/undecodable field (or an undeclared balance key) loads
|
||||||
|
-- as an empty counter set -- the window simply restarts. Stale-cache guards
|
||||||
|
-- may return in a future iteration.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
local USAGE_WINDOWS_FIELD = '_usage_windows'
|
||||||
|
|
||||||
|
-- ONE mutable counter row per scope: a row matches its limit on
|
||||||
|
-- internal_entity_id alone. Bounds are payload, not identity.
|
||||||
|
local function find_usage_window(windows, limit)
|
||||||
|
local limit_entity = limit.internal_entity_id
|
||||||
|
for _, window in ipairs(windows) do
|
||||||
|
if type(window) == 'table' then
|
||||||
|
local window_entity = window.internal_entity_id
|
||||||
|
local entities_match =
|
||||||
|
(is_nil(limit_entity) and is_nil(window_entity))
|
||||||
|
or limit_entity == window_entity
|
||||||
|
if entities_match then
|
||||||
|
return window
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Returns { [feature_id] = { balance_key, windows, dirty, limit,
|
||||||
|
-- dimension_type, headroom, consumed } }, one entry per distinct capped
|
||||||
|
-- feature in usage_window_limits.
|
||||||
|
local function read_usage_windows(params)
|
||||||
|
local limits = params.usage_window_limits or {}
|
||||||
|
local balance_keys_by_feature_id =
|
||||||
|
safe_table(params.balance_keys_by_feature_id)
|
||||||
|
local usage_windows = {}
|
||||||
|
|
||||||
|
for _, limit in ipairs(limits) do
|
||||||
|
local feature_id = limit.feature_id
|
||||||
|
if usage_windows[feature_id] == nil then
|
||||||
|
local balance_key = balance_keys_by_feature_id[feature_id]
|
||||||
|
local windows = nil
|
||||||
|
|
||||||
|
if not is_nil(balance_key) then
|
||||||
|
local raw_value = redis.call('HGET', balance_key, USAGE_WINDOWS_FIELD)
|
||||||
|
windows = safe_decode(raw_value)
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(windows) ~= 'table' then
|
||||||
|
windows = new_empty_array()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- cjson decodes an empty JSON object ({}) to the same empty table as [];
|
||||||
|
-- a non-empty map-like blob should be impossible for this field, but
|
||||||
|
-- reset it defensively rather than letting ipairs skip rows silently.
|
||||||
|
if next(windows) ~= nil and windows[1] == nil then
|
||||||
|
windows = new_empty_array()
|
||||||
|
end
|
||||||
|
|
||||||
|
local existing = find_usage_window(windows, limit)
|
||||||
|
-- A count is valid only within its exact stamped window: derive 0 when
|
||||||
|
-- it expired OR its bounds no longer match the current derivation (the
|
||||||
|
-- lazy roll persists the zero; this read must not trust it blindly).
|
||||||
|
local current_usage = 0
|
||||||
|
if not is_nil(existing)
|
||||||
|
and safe_number(existing.window_end_at) > safe_number(params.now)
|
||||||
|
and safe_number(existing.window_start_at) == limit.window_start_at
|
||||||
|
then
|
||||||
|
current_usage = safe_number(existing.usage)
|
||||||
|
end
|
||||||
|
local headroom = safe_number(limit.limit) - current_usage
|
||||||
|
if headroom < 0 then
|
||||||
|
headroom = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
usage_windows[feature_id] = {
|
||||||
|
balance_key = not is_nil(balance_key) and balance_key or nil,
|
||||||
|
windows = windows,
|
||||||
|
dirty = false,
|
||||||
|
limit = limit,
|
||||||
|
dimension_type = limit.dimension_type,
|
||||||
|
headroom = headroom,
|
||||||
|
consumed = 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return usage_windows
|
||||||
|
end
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- USAGE WINDOW CONTEXT UTILITIES (V2)
|
||||||
|
-- Hard windowed usage-limit enforcement against context.usage_windows (loaded
|
||||||
|
-- by init_context via read_usage_windows), integrated into the deduction
|
||||||
|
-- passes the same way spend limits are:
|
||||||
|
-- per-ent gate (get_available_from_usage_windows, in the deduction loop)
|
||||||
|
-- -> consume headroom as each ent drains (consume_usage_window_headroom)
|
||||||
|
-- -> update_in_memory_usage_window (mark dirty) -> apply_usage_window_writes.
|
||||||
|
-- A window-capped leftover is handled by the standard overage_behaviour path
|
||||||
|
-- ('cap' applies the partial deduction, 'reject' returns INSUFFICIENT_BALANCE)
|
||||||
|
-- -- no window-specific error.
|
||||||
|
--
|
||||||
|
-- CONFIG IN: usage_window_limits[] -- resolved caps (limit, bounds,
|
||||||
|
-- dimension) from fullSubjectToUsageWindowLimits.
|
||||||
|
-- COUNTERS OUT: context.usage_windows[feature_id].windows -- DbUsageWindow
|
||||||
|
-- rows (usage amounts), NOT the config.
|
||||||
|
--
|
||||||
|
-- Units: the deduction loop works in TRACKED-FEATURE UNITS; each ent's
|
||||||
|
-- credit_cost converts them to that ent's balance units. A metered_feature
|
||||||
|
-- limit counts tracked units (applies to every ent in the deduction set); a
|
||||||
|
-- balance limit counts credits drained from ents OF the capped feature, so
|
||||||
|
-- headroom converts via credit_cost at the gate.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Tolerance for float drift (credit-ratio conversions leave sub-nano noise).
|
||||||
|
local USAGE_WINDOW_EPSILON = 1e-9
|
||||||
|
|
||||||
|
-- Max tracked units deductible from ONE ent given every applicable window
|
||||||
|
-- limit, or nil when unbounded. Windows never store a conversion -- headroom
|
||||||
|
-- lives in the limit's own unit (tracked units for metered dims, credits for
|
||||||
|
-- balance dims) and is converted HERE, per call, with the calling ent's
|
||||||
|
-- credit_cost: the same balance-dim headroom yields different unit allowances
|
||||||
|
-- for ents with different credit ratios, and only ents OF the capped feature
|
||||||
|
-- are bound by it at all. Metered dims need no conversion (the deduction loop
|
||||||
|
-- is denominated in tracked units, whatever pool funds them).
|
||||||
|
--
|
||||||
|
-- Pass ent_feature_id = nil for the rollover phase: metered limits still
|
||||||
|
-- apply (rollover drains consume tracked units), balance limits do not
|
||||||
|
-- (parity with spend limits, whose overage math also ignores rollover
|
||||||
|
-- drains).
|
||||||
|
local function get_available_from_usage_windows(params)
|
||||||
|
local context = params.context
|
||||||
|
local ent_feature_id = params.ent_feature_id
|
||||||
|
local credit_cost = params.credit_cost or 1
|
||||||
|
local allowed = nil
|
||||||
|
|
||||||
|
for feature_id, feature_windows in pairs(context.usage_windows or {}) do
|
||||||
|
local headroom = feature_windows.headroom
|
||||||
|
if headroom <= USAGE_WINDOW_EPSILON then
|
||||||
|
headroom = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local units = nil
|
||||||
|
if feature_windows.dimension_type ~= 'balance' then
|
||||||
|
units = headroom
|
||||||
|
elseif not is_nil(ent_feature_id) and feature_id == ent_feature_id then
|
||||||
|
units = headroom / credit_cost
|
||||||
|
end
|
||||||
|
|
||||||
|
if units ~= nil and (allowed == nil or units < allowed) then
|
||||||
|
allowed = units
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return allowed
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Records `units` tracked units drained from an ent against every applicable
|
||||||
|
-- limit: metered limits consume units 1:1, balance limits consume
|
||||||
|
-- units * credit_cost (credits). Decrements live headroom so the next ent's
|
||||||
|
-- gate sees it, and accumulates `consumed` for the counter increment.
|
||||||
|
local function consume_usage_window_headroom(params)
|
||||||
|
local context = params.context
|
||||||
|
local ent_feature_id = params.ent_feature_id
|
||||||
|
local credit_cost = params.credit_cost or 1
|
||||||
|
local units = params.units or 0
|
||||||
|
|
||||||
|
if units <= 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
for feature_id, feature_windows in pairs(context.usage_windows or {}) do
|
||||||
|
local consumed = nil
|
||||||
|
if feature_windows.dimension_type ~= 'balance' then
|
||||||
|
consumed = units
|
||||||
|
elseif not is_nil(ent_feature_id) and feature_id == ent_feature_id then
|
||||||
|
consumed = units * credit_cost
|
||||||
|
end
|
||||||
|
|
||||||
|
if consumed ~= nil and consumed > 0 then
|
||||||
|
feature_windows.headroom = feature_windows.headroom - consumed
|
||||||
|
if feature_windows.headroom < 0 then
|
||||||
|
feature_windows.headroom = 0
|
||||||
|
end
|
||||||
|
feature_windows.consumed = feature_windows.consumed + consumed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Sibling of append_mutation_log: records which window row moved and by how
|
||||||
|
-- much (usage_delta in the limit's native unit). Kept as its own stream so
|
||||||
|
-- mutation_logs stays entitlement/rollover-shaped.
|
||||||
|
local function append_usage_window_mutation(params)
|
||||||
|
local context = params.context
|
||||||
|
table.insert(context.usage_window_mutations, {
|
||||||
|
usage_window_id = params.usage_window_id or cjson.null,
|
||||||
|
feature_id = params.feature_id,
|
||||||
|
internal_entity_id = params.internal_entity_id or cjson.null,
|
||||||
|
window_start_at = params.window_start_at,
|
||||||
|
usage_delta = params.usage_delta or 0,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- In-memory mutation for one limit (sibling of
|
||||||
|
-- update_in_memory_customer_entitlement_mutation). ONE mutable row per scope:
|
||||||
|
-- zero the count if its stored window closed (defensive guard -- the lazy
|
||||||
|
-- roll action owns the roll), stamp the current bounds/anchor, add consumed.
|
||||||
|
local function update_in_memory_usage_window(params)
|
||||||
|
local context = params.context
|
||||||
|
local limit = params.limit
|
||||||
|
local now = params.now
|
||||||
|
|
||||||
|
local feature_windows = context.usage_windows[limit.feature_id]
|
||||||
|
if feature_windows == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if feature_windows.consumed > USAGE_WINDOW_EPSILON then
|
||||||
|
local existing = find_usage_window(feature_windows.windows, limit)
|
||||||
|
if is_nil(existing) then
|
||||||
|
-- The TS-minted candidate id is used ONLY at creation; under concurrency
|
||||||
|
-- the second request finds the first one's row and its id is discarded.
|
||||||
|
existing = {
|
||||||
|
id = limit.new_window_id,
|
||||||
|
internal_customer_id = limit.internal_customer_id,
|
||||||
|
internal_entity_id = limit.internal_entity_id,
|
||||||
|
feature_id = limit.feature_id,
|
||||||
|
internal_feature_id = limit.internal_feature_id,
|
||||||
|
usage = 0,
|
||||||
|
}
|
||||||
|
table.insert(feature_windows.windows, existing)
|
||||||
|
elseif safe_number(existing.window_end_at) <= now
|
||||||
|
or safe_number(existing.window_start_at) ~= limit.window_start_at
|
||||||
|
then
|
||||||
|
-- A count never survives its stamped window: zero on expiry AND on any
|
||||||
|
-- bounds re-derivation mismatch (plan change).
|
||||||
|
existing.usage = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
existing.window_start_at = limit.window_start_at
|
||||||
|
existing.window_end_at = limit.window_end_at
|
||||||
|
existing.anchor_customer_entitlement_id =
|
||||||
|
limit.anchor_customer_entitlement_id
|
||||||
|
existing.usage = safe_number(existing.usage) + feature_windows.consumed
|
||||||
|
existing.updated_at = now
|
||||||
|
feature_windows.dirty = true
|
||||||
|
|
||||||
|
append_usage_window_mutation({
|
||||||
|
context = context,
|
||||||
|
usage_window_id = existing.id,
|
||||||
|
feature_id = limit.feature_id,
|
||||||
|
internal_entity_id = limit.internal_entity_id,
|
||||||
|
window_start_at = limit.window_start_at,
|
||||||
|
usage_delta = feature_windows.consumed,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Applies each limit's in-flight consumed amount to its counter row.
|
||||||
|
-- Mirrors a lock UNWIND onto the counters: each applied unwind iteration
|
||||||
|
-- frees window headroom (metered dims by tracked units, balance dims by the
|
||||||
|
-- credits restored to that feature's entitlements). Clamped at 0; only the
|
||||||
|
-- limit's CURRENT window is decremented (a roll between lock and unwind
|
||||||
|
-- forfeits the old window's count, which is the conservative outcome).
|
||||||
|
local function decrement_usage_windows_for_unwind(params)
|
||||||
|
local context = params.context
|
||||||
|
local iterations = safe_table(params.iterations)
|
||||||
|
local now = params.now
|
||||||
|
|
||||||
|
if is_nil(context.usage_windows) or #iterations == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local total_units = 0
|
||||||
|
local credits_by_feature_id = {}
|
||||||
|
|
||||||
|
for _, iteration in ipairs(iterations) do
|
||||||
|
local units = safe_number(iteration.unwind_iteration_value)
|
||||||
|
total_units = total_units + units
|
||||||
|
|
||||||
|
local item = iteration.item or {}
|
||||||
|
local ent_feature_id = nil
|
||||||
|
local ent = context.customer_entitlements[item.customer_entitlement_id]
|
||||||
|
if ent then
|
||||||
|
ent_feature_id = ent.feature_id
|
||||||
|
elseif item.rollover_id and context.rollovers[item.rollover_id] then
|
||||||
|
local rollover_ent = context.customer_entitlements[
|
||||||
|
context.rollovers[item.rollover_id].cus_ent_id
|
||||||
|
]
|
||||||
|
if rollover_ent then
|
||||||
|
ent_feature_id = rollover_ent.feature_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if ent_feature_id then
|
||||||
|
local credits = units * safe_number(item.credit_cost or 1)
|
||||||
|
credits_by_feature_id[ent_feature_id] =
|
||||||
|
(credits_by_feature_id[ent_feature_id] or 0) + credits
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for feature_id, feature_windows in pairs(context.usage_windows) do
|
||||||
|
local amount = 0
|
||||||
|
if feature_windows.dimension_type == 'balance' then
|
||||||
|
amount = credits_by_feature_id[feature_id] or 0
|
||||||
|
else
|
||||||
|
amount = total_units
|
||||||
|
end
|
||||||
|
|
||||||
|
if amount > 0 then
|
||||||
|
local existing = find_usage_window(
|
||||||
|
feature_windows.windows,
|
||||||
|
feature_windows.limit
|
||||||
|
)
|
||||||
|
if not is_nil(existing) then
|
||||||
|
local current = safe_number(existing.usage)
|
||||||
|
local next_usage = current - amount
|
||||||
|
if next_usage < 0 then
|
||||||
|
next_usage = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
if next_usage ~= current then
|
||||||
|
existing.usage = next_usage
|
||||||
|
existing.updated_at = now
|
||||||
|
feature_windows.dirty = true
|
||||||
|
|
||||||
|
append_usage_window_mutation({
|
||||||
|
context = context,
|
||||||
|
usage_window_id = existing.id,
|
||||||
|
feature_id = feature_windows.limit.feature_id,
|
||||||
|
internal_entity_id = feature_windows.limit.internal_entity_id,
|
||||||
|
window_start_at = feature_windows.limit.window_start_at,
|
||||||
|
usage_delta = next_usage - current,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function increment_usage_window_counters(params)
|
||||||
|
local context = params.context
|
||||||
|
local limits = params.usage_window_limits or {}
|
||||||
|
|
||||||
|
for _, limit in ipairs(limits) do
|
||||||
|
update_in_memory_usage_window({
|
||||||
|
context = context,
|
||||||
|
limit = limit,
|
||||||
|
now = params.now,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Persists dirty counter arrays back to their '_usage_windows' fields
|
||||||
|
-- (sibling of apply_pending_writes; direct HSET like updateAggregatedBalances
|
||||||
|
-- since the cusEnt pending-write path is keyed by entitlement blobs).
|
||||||
|
-- The EXPIRE guard is load-bearing under fail-open: a write to a hash that
|
||||||
|
-- did not exist (capped feature with no entitlements and no rebuild yet)
|
||||||
|
-- must not create an immortal key.
|
||||||
|
local function apply_usage_window_writes(context, ttl_seconds)
|
||||||
|
local ttl = tonumber(ttl_seconds)
|
||||||
|
|
||||||
|
for _, feature_windows in pairs(context.usage_windows or {}) do
|
||||||
|
if feature_windows.dirty and not is_nil(feature_windows.balance_key) then
|
||||||
|
redis.call(
|
||||||
|
'HSET',
|
||||||
|
feature_windows.balance_key,
|
||||||
|
USAGE_WINDOWS_FIELD,
|
||||||
|
cjson.encode(feature_windows.windows)
|
||||||
|
)
|
||||||
|
if ttl and ttl > 0
|
||||||
|
and redis.call('TTL', feature_windows.balance_key) < 0 then
|
||||||
|
redis.call('EXPIRE', feature_windows.balance_key, ttl)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Result payload: { [feature_id] = windows[] } for every loaded capped
|
||||||
|
-- feature, so the TS caller can refresh the in-flight subject and hand the
|
||||||
|
-- post-deduction counters to syncItemV4 (no Redis re-read).
|
||||||
|
local function usage_windows_to_result(context)
|
||||||
|
local result = nil
|
||||||
|
for feature_id, feature_windows in pairs(context.usage_windows or {}) do
|
||||||
|
if result == nil then
|
||||||
|
result = {}
|
||||||
|
end
|
||||||
|
result[feature_id] = feature_windows.windows
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
@@ -44,11 +44,14 @@ import READ_SUBJECT_BALANCES from "./fullSubjectDeduction/readSubjectBalances.lu
|
|||||||
import RUN_DEDUCTION_ON_CONTEXT_V2 from "./fullSubjectDeduction/runDeductionOnContextV2.lua";
|
import RUN_DEDUCTION_ON_CONTEXT_V2 from "./fullSubjectDeduction/runDeductionOnContextV2.lua";
|
||||||
import SPEND_LIMIT_UTILS_V2 from "./fullSubjectDeduction/spendLimitUtilsV2.lua";
|
import SPEND_LIMIT_UTILS_V2 from "./fullSubjectDeduction/spendLimitUtilsV2.lua";
|
||||||
import UPDATE_AGGREGATED_BALANCES from "./fullSubjectDeduction/updateAggregatedBalances.lua";
|
import UPDATE_AGGREGATED_BALANCES from "./fullSubjectDeduction/updateAggregatedBalances.lua";
|
||||||
|
import READ_USAGE_WINDOWS from "./fullSubjectDeduction/usageWindows/readUsageWindows.lua";
|
||||||
|
import USAGE_WINDOW_CONTEXT_UTILS_V2 from "./fullSubjectDeduction/usageWindows/usageWindowContextUtilsV2.lua";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// UPDATE SUBJECT BALANCES HELPERS (V2 cache — per-feature hash updates)
|
// UPDATE SUBJECT BALANCES HELPERS (V2 cache — per-feature hash updates)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
import ROLL_USAGE_WINDOWS_MAIN from "./fullSubject/rollUsageWindows/rollUsageWindows.lua";
|
||||||
import APPLY_FIELD_UPDATES from "./fullSubject/updateSubjectBalances/applyFieldUpdates.lua";
|
import APPLY_FIELD_UPDATES from "./fullSubject/updateSubjectBalances/applyFieldUpdates.lua";
|
||||||
import UPDATE_CONTEXT_UTILS from "./fullSubject/updateSubjectBalances/updateContextUtils.lua";
|
import UPDATE_CONTEXT_UTILS from "./fullSubject/updateSubjectBalances/updateContextUtils.lua";
|
||||||
import UPDATE_SUBJECT_BALANCES_MAIN from "./fullSubject/updateSubjectBalances/updateSubjectBalances.lua";
|
import UPDATE_SUBJECT_BALANCES_MAIN from "./fullSubject/updateSubjectBalances/updateSubjectBalances.lua";
|
||||||
@@ -200,11 +203,13 @@ export const UPDATE_CUSTOMER_PRODUCT_SCRIPT =
|
|||||||
*/
|
*/
|
||||||
export const DEDUCT_FROM_SUBJECT_BALANCES_SCRIPT = `${LUA_UTILS}
|
export const DEDUCT_FROM_SUBJECT_BALANCES_SCRIPT = `${LUA_UTILS}
|
||||||
${READ_SUBJECT_BALANCES}
|
${READ_SUBJECT_BALANCES}
|
||||||
|
${READ_USAGE_WINDOWS}
|
||||||
${CONTEXT_UTILS_V2}
|
${CONTEXT_UTILS_V2}
|
||||||
${GET_TOTAL_BALANCE}
|
${GET_TOTAL_BALANCE}
|
||||||
${DEDUCT_FROM_ROLLOVERS_V2}
|
${DEDUCT_FROM_ROLLOVERS_V2}
|
||||||
${DEDUCT_FROM_MAIN_BALANCE_V2}
|
${DEDUCT_FROM_MAIN_BALANCE_V2}
|
||||||
${SPEND_LIMIT_UTILS_V2}
|
${SPEND_LIMIT_UTILS_V2}
|
||||||
|
${USAGE_WINDOW_CONTEXT_UTILS_V2}
|
||||||
${RUN_DEDUCTION_ON_CONTEXT_V2}
|
${RUN_DEDUCTION_ON_CONTEXT_V2}
|
||||||
${MUTATION_ITEM_UTILS}
|
${MUTATION_ITEM_UTILS}
|
||||||
${LOCK_RECEIPT_UTILS_V2}
|
${LOCK_RECEIPT_UTILS_V2}
|
||||||
@@ -238,3 +243,11 @@ ${UPDATE_CONTEXT_UTILS}
|
|||||||
${APPLY_FIELD_UPDATES}
|
${APPLY_FIELD_UPDATES}
|
||||||
${UPDATE_AGGREGATED_BALANCES}
|
${UPDATE_AGGREGATED_BALANCES}
|
||||||
${UPDATE_SUBJECT_BALANCES_MAIN}`;
|
${UPDATE_SUBJECT_BALANCES_MAIN}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lua script for atomically rolling usage-window counters in a per-feature
|
||||||
|
* balance hash's '_usage_windows' field (zero expired counts, advance
|
||||||
|
* bounds/anchor). Called once per feature via pipeline by the lazy roll.
|
||||||
|
*/
|
||||||
|
export const ROLL_USAGE_WINDOWS_SCRIPT = `${LUA_UTILS}
|
||||||
|
${ROLL_USAGE_WINDOWS_MAIN}`;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const shed503OnTransientError = async <T>({
|
|||||||
if (!(isTransientDbError({ error }) || isTransientRedisError({ error }))) {
|
if (!(isTransientDbError({ error }) || isTransientRedisError({ error }))) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
ctx.logger.warn(`[${source}] DB unavailable, shedding with 503`, {
|
ctx.logger.warn(`[${source}] transient DB error, shedding with 503`, {
|
||||||
type: `${source}_fail_open`,
|
type: `${source}_fail_open`,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -114,16 +114,12 @@ export const createRedisAvailability = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldReconnectReadyClient =
|
const shouldReconnectReadyClient =
|
||||||
failedWhileReady &&
|
failedWhileReady && consecutiveFailures + 1 >= REDIS_FAILURES_TO_DEGRADE;
|
||||||
consecutiveFailures + 1 >= REDIS_FAILURES_TO_DEGRADE;
|
|
||||||
|
|
||||||
if (shouldReconnectReadyClient) {
|
if (shouldReconnectReadyClient) {
|
||||||
await reconnectRedis();
|
await reconnectRedis();
|
||||||
} else if (redis.status !== "ready") {
|
} else if (redis.status !== "ready") {
|
||||||
if (
|
if (redis.status === "connecting" || redis.status === "reconnecting") {
|
||||||
redis.status === "connecting" ||
|
|
||||||
redis.status === "reconnecting"
|
|
||||||
) {
|
|
||||||
reconnectStartedAt ??= Date.now();
|
reconnectStartedAt ??= Date.now();
|
||||||
if (Date.now() - reconnectStartedAt < REDIS_STALE_RECONNECT_MS) {
|
if (Date.now() - reconnectStartedAt < REDIS_STALE_RECONNECT_MS) {
|
||||||
return false;
|
return false;
|
||||||
@@ -149,10 +145,7 @@ export const createRedisAvailability = ({
|
|||||||
return {
|
return {
|
||||||
prime: async () => {
|
prime: async () => {
|
||||||
if (!hasConfig) return;
|
if (!hasConfig) return;
|
||||||
if (
|
if (redis.status === "connecting" || redis.status === "reconnecting") {
|
||||||
redis.status === "connecting" ||
|
|
||||||
redis.status === "reconnecting"
|
|
||||||
) {
|
|
||||||
await waitForRedisReady(redis, logPrefix).catch(() => undefined);
|
await waitForRedisReady(redis, logPrefix).catch(() => undefined);
|
||||||
}
|
}
|
||||||
const available = await probeRedisAvailability();
|
const available = await probeRedisAvailability();
|
||||||
@@ -174,8 +167,7 @@ export const createRedisAvailability = ({
|
|||||||
clearInterval(redisMonitorInterval);
|
clearInterval(redisMonitorInterval);
|
||||||
redisMonitorInterval = null;
|
redisMonitorInterval = null;
|
||||||
},
|
},
|
||||||
shouldUseRedis: () =>
|
shouldUseRedis: () => hasConfig && redisAvailabilityState === "healthy",
|
||||||
hasConfig && redisAvailabilityState === "healthy",
|
|
||||||
getRedisAvailability: (): RedisAvailabilitySnapshot => ({
|
getRedisAvailability: (): RedisAvailabilitySnapshot => ({
|
||||||
configured: hasConfig,
|
configured: hasConfig,
|
||||||
state: redisAvailabilityState,
|
state: redisAvailabilityState,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { redis } from "./redisClientRegistry.js";
|
|
||||||
import {
|
import {
|
||||||
createRedisAvailability,
|
createRedisAvailability,
|
||||||
type RedisAvailabilitySnapshot,
|
type RedisAvailabilitySnapshot,
|
||||||
} from "./createRedisAvailability.js";
|
} from "./createRedisAvailability.js";
|
||||||
|
import { redis } from "./redisClientRegistry.js";
|
||||||
import { hasRedisConfig } from "./redisConfig.js";
|
import { hasRedisConfig } from "./redisConfig.js";
|
||||||
|
|
||||||
const redisAvailability = createRedisAvailability({
|
const redisAvailability = createRedisAvailability({
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ declare module "ioredis" {
|
|||||||
balanceKey: string,
|
balanceKey: string,
|
||||||
paramsJson: string,
|
paramsJson: string,
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
|
rollUsageWindows(balanceKey: string, paramsJson: string): Promise<string>;
|
||||||
deleteFullCustomerCache(
|
deleteFullCustomerCache(
|
||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
testGuardKey: string,
|
testGuardKey: string,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
DEDUCT_FROM_SUBJECT_BALANCES_SCRIPT,
|
DEDUCT_FROM_SUBJECT_BALANCES_SCRIPT,
|
||||||
DELETE_FULL_CUSTOMER_CACHE_SCRIPT,
|
DELETE_FULL_CUSTOMER_CACHE_SCRIPT,
|
||||||
RESET_CUSTOMER_ENTITLEMENTS_SCRIPT,
|
RESET_CUSTOMER_ENTITLEMENTS_SCRIPT,
|
||||||
|
ROLL_USAGE_WINDOWS_SCRIPT,
|
||||||
SET_CACHED_FULL_SUBJECT_SCRIPT,
|
SET_CACHED_FULL_SUBJECT_SCRIPT,
|
||||||
SET_FULL_CUSTOMER_CACHE_SCRIPT,
|
SET_FULL_CUSTOMER_CACHE_SCRIPT,
|
||||||
UPDATE_CACHED_INVOICE_V2_SCRIPT,
|
UPDATE_CACHED_INVOICE_V2_SCRIPT,
|
||||||
@@ -127,6 +128,11 @@ export const registerRedisCommands = ({
|
|||||||
lua: prepareScript(UPDATE_SUBJECT_BALANCES_SCRIPT),
|
lua: prepareScript(UPDATE_SUBJECT_BALANCES_SCRIPT),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
redisInstance.defineCommand("rollUsageWindows", {
|
||||||
|
numberOfKeys: 1,
|
||||||
|
lua: prepareScript(ROLL_USAGE_WINDOWS_SCRIPT),
|
||||||
|
});
|
||||||
|
|
||||||
redisInstance.defineCommand("deleteFullCustomerCache", {
|
redisInstance.defineCommand("deleteFullCustomerCache", {
|
||||||
numberOfKeys: 4,
|
numberOfKeys: 4,
|
||||||
lua: DELETE_FULL_CUSTOMER_CACHE_SCRIPT,
|
lua: DELETE_FULL_CUSTOMER_CACHE_SCRIPT,
|
||||||
|
|||||||
@@ -3,16 +3,18 @@ import { shouldUseRedisV2 } from "@/external/redis/initUtils/redisV2Availability
|
|||||||
import { RedisUnavailableError } from "./errors.js";
|
import { RedisUnavailableError } from "./errors.js";
|
||||||
import { isTransientRedisError } from "./isTransientRedisError.js";
|
import { isTransientRedisError } from "./isTransientRedisError.js";
|
||||||
|
|
||||||
/** Runs `run`. If Redis is unavailable or a transient DB error occurs,
|
/** Runs `run`. If Redis is unavailable, a transient DB error occurs, or
|
||||||
* calls `fallback`. Any other error propagates. */
|
* `alsoFailOpen` matches, calls `fallback`. Any other error propagates. */
|
||||||
export const withRedisFailOpen = async <T>({
|
export const withRedisFailOpen = async <T>({
|
||||||
source,
|
source,
|
||||||
run,
|
run,
|
||||||
fallback,
|
fallback,
|
||||||
|
alsoFailOpen,
|
||||||
}: {
|
}: {
|
||||||
source: string;
|
source: string;
|
||||||
run: () => T | Promise<T>;
|
run: () => T | Promise<T>;
|
||||||
fallback: (error: unknown) => T | Promise<T>;
|
fallback: (error: unknown) => T | Promise<T>;
|
||||||
|
alsoFailOpen?: (error: unknown) => boolean;
|
||||||
}): Promise<T> => {
|
}): Promise<T> => {
|
||||||
try {
|
try {
|
||||||
if (!shouldUseRedisV2()) {
|
if (!shouldUseRedisV2()) {
|
||||||
@@ -21,7 +23,11 @@ export const withRedisFailOpen = async <T>({
|
|||||||
|
|
||||||
return await run();
|
return await run();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isTransientRedisError({ error }) || isTransientDbError({ error })) {
|
if (
|
||||||
|
isTransientRedisError({ error }) ||
|
||||||
|
isTransientDbError({ error }) ||
|
||||||
|
alsoFailOpen?.(error)
|
||||||
|
) {
|
||||||
return await fallback(error);
|
return await fallback(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
atmnToStripeAmount,
|
||||||
type EntitlementWithFeature,
|
type EntitlementWithFeature,
|
||||||
type FeatureOptions,
|
type FeatureOptions,
|
||||||
featureOptionUtils,
|
featureOptionUtils,
|
||||||
@@ -58,7 +59,10 @@ export const priceToOneOffAndTiered = ({
|
|||||||
product: config.stripe_product_id
|
product: config.stripe_product_id
|
||||||
? config.stripe_product_id
|
? config.stripe_product_id
|
||||||
: stripeProductId,
|
: stripeProductId,
|
||||||
unit_amount: Number(amount.toFixed(2)) * 100,
|
unit_amount: atmnToStripeAmount({
|
||||||
|
amount,
|
||||||
|
currency: orgToCurrency({ org }),
|
||||||
|
}),
|
||||||
currency: orgToCurrency({ org }),
|
currency: orgToCurrency({ org }),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
atmnToStripeAmount,
|
||||||
BillingInterval,
|
BillingInterval,
|
||||||
type Customer,
|
type Customer,
|
||||||
type Feature,
|
type Feature,
|
||||||
@@ -95,7 +96,7 @@ export const getInvoiceItemForUsage = ({
|
|||||||
|
|
||||||
price_data: {
|
price_data: {
|
||||||
product: config.stripe_product_id!,
|
product: config.stripe_product_id!,
|
||||||
unit_amount: Math.max(Math.round(amount * 100), 0),
|
unit_amount: Math.max(atmnToStripeAmount({ amount, currency }), 0),
|
||||||
currency,
|
currency,
|
||||||
},
|
},
|
||||||
period: {
|
period: {
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ export const stripeWebhookRouter = new Hono<StripeWebhookHonoEnv>();
|
|||||||
stripeWebhookRouter.post(
|
stripeWebhookRouter.post(
|
||||||
"/webhooks/stripe/:orgId/:env",
|
"/webhooks/stripe/:orgId/:env",
|
||||||
stripeLegacySeederMiddleware,
|
stripeLegacySeederMiddleware,
|
||||||
|
stripeToAutumnCustomerMiddleware,
|
||||||
stripeIdempotencyMiddleware,
|
stripeIdempotencyMiddleware,
|
||||||
stripeWebhookEarlyAckMiddleware,
|
stripeWebhookEarlyAckMiddleware,
|
||||||
stripeWebhookRefreshMiddleware,
|
stripeWebhookRefreshMiddleware,
|
||||||
stripeSyncMiddleware,
|
stripeSyncMiddleware,
|
||||||
stripeToAutumnCustomerMiddleware,
|
|
||||||
stripeLoggerMiddleware,
|
stripeLoggerMiddleware,
|
||||||
traceEnrichMiddleware,
|
traceEnrichMiddleware,
|
||||||
handleStripeWebhookEvent,
|
handleStripeWebhookEvent,
|
||||||
@@ -31,11 +31,11 @@ stripeWebhookRouter.post(
|
|||||||
stripeWebhookRouter.post(
|
stripeWebhookRouter.post(
|
||||||
"/webhooks/connect/:env",
|
"/webhooks/connect/:env",
|
||||||
stripeConnectSeederMiddleware,
|
stripeConnectSeederMiddleware,
|
||||||
|
stripeToAutumnCustomerMiddleware,
|
||||||
stripeIdempotencyMiddleware,
|
stripeIdempotencyMiddleware,
|
||||||
stripeWebhookEarlyAckMiddleware,
|
stripeWebhookEarlyAckMiddleware,
|
||||||
stripeWebhookRefreshMiddleware,
|
stripeWebhookRefreshMiddleware,
|
||||||
stripeSyncMiddleware,
|
stripeSyncMiddleware,
|
||||||
stripeToAutumnCustomerMiddleware,
|
|
||||||
stripeLoggerMiddleware,
|
stripeLoggerMiddleware,
|
||||||
traceEnrichMiddleware,
|
traceEnrichMiddleware,
|
||||||
handleStripeWebhookEvent,
|
handleStripeWebhookEvent,
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { createStripeScheduleFromCheckout } from "@/external/stripe/webhookHandl
|
|||||||
import { modifyStripeSubscriptionFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/modifyStripeSubscriptionFromCheckout";
|
import { modifyStripeSubscriptionFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/modifyStripeSubscriptionFromCheckout";
|
||||||
import { syncSubscriptionItemMetadataFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout";
|
import { syncSubscriptionItemMetadataFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout";
|
||||||
import { updateBillingPlanFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/updateBillingPlanFromCheckout";
|
import { updateBillingPlanFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/updateBillingPlanFromCheckout";
|
||||||
|
import { withClaimedCheckoutSessionMetadata } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/withClaimedCheckoutSessionMetadata";
|
||||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||||
import { persistDeferredCreateSchedule } from "@/internal/billing/v2/actions/createSchedule/utils/persistDeferredCreateSchedule";
|
import { persistDeferredCreateSchedule } from "@/internal/billing/v2/actions/createSchedule/utils/persistDeferredCreateSchedule";
|
||||||
import { checkoutSessionLock } from "@/internal/billing/v2/actions/locks/checkoutSessionLock/checkoutSessionLock";
|
|
||||||
import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan";
|
import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan";
|
||||||
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
|
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
|
||||||
import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan";
|
import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan";
|
||||||
@@ -34,6 +34,24 @@ export const handleCheckoutSessionMetadataV2 = async ({
|
|||||||
`[checkout.completed] Handling checkout session metadata V2: ${metadata.id}`,
|
`[checkout.completed] Handling checkout session metadata V2: ${metadata.id}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await withClaimedCheckoutSessionMetadata({
|
||||||
|
ctx,
|
||||||
|
checkoutContext,
|
||||||
|
metadata,
|
||||||
|
execute: () =>
|
||||||
|
executeCheckoutSessionMetadataV2({ ctx, checkoutContext, metadata }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeCheckoutSessionMetadataV2 = async ({
|
||||||
|
ctx,
|
||||||
|
checkoutContext,
|
||||||
|
metadata,
|
||||||
|
}: {
|
||||||
|
ctx: StripeWebhookContext;
|
||||||
|
checkoutContext: CheckoutSessionCompletedContext;
|
||||||
|
metadata: NonNullable<CheckoutSessionCompletedContext["metadata"]>;
|
||||||
|
}): Promise<void> => {
|
||||||
const deferredData = metadata.data as DeferredAutumnBillingPlanData;
|
const deferredData = metadata.data as DeferredAutumnBillingPlanData;
|
||||||
|
|
||||||
// 1. Sync Autumn metadata onto subscription items created by checkout
|
// 1. Sync Autumn metadata onto subscription items created by checkout
|
||||||
@@ -96,14 +114,6 @@ export const handleCheckoutSessionMetadataV2 = async ({
|
|||||||
billingPlan: updatedDeferredData.billingPlan,
|
billingPlan: updatedDeferredData.billingPlan,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear checkout session lock now that customer_product rows exist
|
|
||||||
const lockCustomerId =
|
|
||||||
updatedDeferredData.billingContext.fullCustomer.id ??
|
|
||||||
updatedDeferredData.billingContext.fullCustomer.internal_id;
|
|
||||||
if (lockCustomerId) {
|
|
||||||
await checkoutSessionLock.clear({ ctx, customerId: lockCustomerId });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Queue customer.products.updated webhook (mirrors executeBillingPlan)
|
// Queue customer.products.updated webhook (mirrors executeBillingPlan)
|
||||||
await billingPlanToSendProductsUpdated({
|
await billingPlanToSendProductsUpdated({
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
type DeferredAutumnBillingPlanData,
|
||||||
|
MetadataType,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import { setStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils";
|
||||||
|
import type { CheckoutSessionCompletedContext } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/setupCheckoutSessionCompletedContext";
|
||||||
|
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||||
|
import { checkoutSessionLock } from "@/internal/billing/v2/actions/locks/checkoutSessionLock/checkoutSessionLock";
|
||||||
|
import { MetadataService } from "@/internal/metadata/MetadataService";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `execute` exactly once across concurrent executors of the same deferred
|
||||||
|
* plan. The subscription lock marks the resulting subscription.updated events
|
||||||
|
* as Autumn-initiated; the checkout session lock is cleared even on failure
|
||||||
|
* since the Stripe session is already paid.
|
||||||
|
*/
|
||||||
|
export const withClaimedCheckoutSessionMetadata = async ({
|
||||||
|
ctx,
|
||||||
|
checkoutContext,
|
||||||
|
metadata,
|
||||||
|
execute,
|
||||||
|
}: {
|
||||||
|
ctx: StripeWebhookContext;
|
||||||
|
checkoutContext: CheckoutSessionCompletedContext;
|
||||||
|
metadata: NonNullable<CheckoutSessionCompletedContext["metadata"]>;
|
||||||
|
execute: () => Promise<void>;
|
||||||
|
}): Promise<void> => {
|
||||||
|
const claimed = await MetadataService.claim({
|
||||||
|
db: ctx.db,
|
||||||
|
id: metadata.id,
|
||||||
|
fromType: MetadataType.CheckoutSessionV2,
|
||||||
|
toType: MetadataType.CheckoutSessionV2Processing,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!claimed) {
|
||||||
|
ctx.logger.info(
|
||||||
|
`[checkout.completed] Metadata ${metadata.id} already claimed by another executor, skipping`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deferredData = metadata.data as DeferredAutumnBillingPlanData;
|
||||||
|
const lockCustomerId =
|
||||||
|
deferredData?.billingContext?.fullCustomer?.id ??
|
||||||
|
deferredData?.billingContext?.fullCustomer?.internal_id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (checkoutContext.stripeSubscription) {
|
||||||
|
await setStripeSubscriptionLock({
|
||||||
|
stripeSubscriptionId: checkoutContext.stripeSubscription.id,
|
||||||
|
lockedAtMs: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await execute();
|
||||||
|
} catch (error) {
|
||||||
|
await revertMetadataClaim({ ctx, metadataId: metadata.id });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (lockCustomerId) {
|
||||||
|
await checkoutSessionLock.clear({ ctx, customerId: lockCustomerId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revertMetadataClaim = async ({
|
||||||
|
ctx,
|
||||||
|
metadataId,
|
||||||
|
}: {
|
||||||
|
ctx: StripeWebhookContext;
|
||||||
|
metadataId: string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
await MetadataService.claim({
|
||||||
|
db: ctx.db,
|
||||||
|
id: metadataId,
|
||||||
|
fromType: MetadataType.CheckoutSessionV2Processing,
|
||||||
|
toType: MetadataType.CheckoutSessionV2,
|
||||||
|
}).catch((revertError) => {
|
||||||
|
ctx.logger.error(
|
||||||
|
`[checkout.completed] Failed to revert metadata claim for ${metadataId}`,
|
||||||
|
{ revertError },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { AttachScenario, cp, type FullCusProduct } from "@autumn/shared";
|
import {
|
||||||
|
AttachScenario,
|
||||||
|
cp,
|
||||||
|
type FullCusProduct,
|
||||||
|
notNullish,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import { msToSeconds } from "@shared/utils/common/unixUtils";
|
||||||
import { getStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils";
|
import { getStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils";
|
||||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||||
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated";
|
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated";
|
||||||
@@ -68,6 +74,13 @@ export const handleStripeSubscriptionCanceled = async ({
|
|||||||
|
|
||||||
if (!isActiveRecurringAndOnSub) continue;
|
if (!isActiveRecurringAndOnSub) continue;
|
||||||
|
|
||||||
|
// attach-set ends_at, not an external cancellation
|
||||||
|
const endedAtMatchesCancelAt =
|
||||||
|
notNullish(customerProduct.ended_at) &&
|
||||||
|
notNullish(cancelsAtMs) &&
|
||||||
|
msToSeconds(customerProduct.ended_at!) === msToSeconds(cancelsAtMs!);
|
||||||
|
if (endedAtMatchesCancelAt) continue;
|
||||||
|
|
||||||
const updates = {
|
const updates = {
|
||||||
canceled_at: canceledAtMs ?? Date.now(),
|
canceled_at: canceledAtMs ?? Date.now(),
|
||||||
canceled: true,
|
canceled: true,
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ export const handleStripeSubscriptionRenewed = async ({
|
|||||||
|
|
||||||
if (!valid) continue;
|
if (!valid) continue;
|
||||||
|
|
||||||
|
// attach-set ends_at expiry, not a cancellation
|
||||||
|
if (!customerProduct.canceled && !customerProduct.canceled_at) continue;
|
||||||
|
|
||||||
// Clear cancellation fields
|
// Clear cancellation fields
|
||||||
const updates = {
|
const updates = {
|
||||||
canceled_at: null,
|
canceled_at: null,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type AppEnv,
|
type AppEnv,
|
||||||
AuthType,
|
AuthType,
|
||||||
|
ErrCode,
|
||||||
type Feature,
|
type Feature,
|
||||||
type Organization,
|
type Organization,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
initMasterStripe,
|
initMasterStripe,
|
||||||
} from "@/external/connect/initStripeCli.js";
|
} from "@/external/connect/initStripeCli.js";
|
||||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||||
|
import RecaseError from "@/utils/errorUtils.js";
|
||||||
import { createStripeCli } from "../../connect/createStripeCli.js";
|
import { createStripeCli } from "../../connect/createStripeCli.js";
|
||||||
import type {
|
import type {
|
||||||
StripeWebhookContext,
|
StripeWebhookContext,
|
||||||
@@ -100,7 +102,18 @@ export const stripeConnectSeederMiddleware = async (
|
|||||||
});
|
});
|
||||||
org = data.org;
|
org = data.org;
|
||||||
features = data.features;
|
features = data.features;
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
// Only ack accounts genuinely not linked to an org; any other failure
|
||||||
|
// (e.g. DB outage) must 500 so Stripe retries instead of dropping the event.
|
||||||
|
const isOrgNotFound =
|
||||||
|
error instanceof RecaseError && error.code === ErrCode.OrgNotFound;
|
||||||
|
if (!isOrgNotFound) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to resolve org for Stripe account ${accountId}, returning 500 for Stripe to retry: ${error}`,
|
||||||
|
);
|
||||||
|
return c.json({ error: "Failed to resolve org for Stripe webhook" }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "development") {
|
if (process.env.NODE_ENV !== "development") {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Account ID ${accountId} not linked to any org, skipping Stripe webhook`,
|
`Account ID ${accountId} not linked to any org, skipping Stripe webhook`,
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export const getCountAndSum = async ({
|
|||||||
`
|
`
|
||||||
: `
|
: `
|
||||||
SELECT event_name, sum(event_count) as count, sum(total_value) as sum
|
SELECT event_name, sum(event_count) as count, sum(total_value) as sum
|
||||||
FROM ${useOrgRollup ? "events_org_hourly_mv" : "events_hourly_no_properties_two_mv"}
|
FROM ${useOrgRollup ? "events_org_hourly_mv" : "events_customer_hourly_mv"}
|
||||||
WHERE org_id = {org_id:String} AND env = {env:String}
|
WHERE org_id = {org_id:String} AND env = {env:String}
|
||||||
${!useOrgRollup && !params.aggregateAll ? "AND customer_id = {customer_id:String}" : ""}
|
${!useOrgRollup && !params.aggregateAll ? "AND customer_id = {customer_id:String}" : ""}
|
||||||
${!useOrgRollup && params.entity_id ? "AND entity_id = {entity_id:String}" : ""}
|
${!useOrgRollup && params.entity_id ? "AND entity_id = {entity_id:String}" : ""}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const getCheckResponseV2 = async ({
|
|||||||
apiSubject: evaluationApiSubject,
|
apiSubject: evaluationApiSubject,
|
||||||
feature: featureToUse,
|
feature: featureToUse,
|
||||||
requiredBalance,
|
requiredBalance,
|
||||||
|
originalFeature,
|
||||||
}).allowed
|
}).allowed
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { withRedisFailOpen } from "@/external/redis/utils/withRedisFailOpen.js";
|
|||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
import type { CheckData } from "@/internal/api/check/checkTypes/CheckData.js";
|
import type { CheckData } from "@/internal/api/check/checkTypes/CheckData.js";
|
||||||
import { getCheckFailOpenFallback } from "@/internal/api/check/checkUtils/getCheckFailOpenFallback.js";
|
import { getCheckFailOpenFallback } from "@/internal/api/check/checkUtils/getCheckFailOpenFallback.js";
|
||||||
|
import { isFullSubjectGateRejection } from "@/internal/customers/repos/getFullSubject/getFullSubjectGate.js";
|
||||||
import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
|
import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
|
||||||
import type { CheckDataV2 } from "./checkTypes/CheckDataV2.js";
|
import type { CheckDataV2 } from "./checkTypes/CheckDataV2.js";
|
||||||
import { runCheckLegacyFlow } from "./runCheckLegacyFlow.js";
|
import { runCheckLegacyFlow } from "./runCheckLegacyFlow.js";
|
||||||
@@ -37,6 +38,7 @@ export const runCheckWithRollout = async ({
|
|||||||
return withRedisFailOpen<RunCheckResult<CheckData | CheckDataV2>>({
|
return withRedisFailOpen<RunCheckResult<CheckData | CheckDataV2>>({
|
||||||
source: "runCheckWithRollout",
|
source: "runCheckWithRollout",
|
||||||
run: () => runCheckV2({ ctx, body, requiredBalance }),
|
run: () => runCheckV2({ ctx, body, requiredBalance }),
|
||||||
|
alsoFailOpen: isFullSubjectGateRejection,
|
||||||
fallback: (error) => ({
|
fallback: (error) => ({
|
||||||
checkData: null,
|
checkData: null,
|
||||||
response: getCheckFailOpenFallback({
|
response: getCheckFailOpenFallback({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type ParsedCheckParams,
|
type ParsedCheckParams,
|
||||||
RecaseError,
|
RecaseError,
|
||||||
type TrackParams,
|
type TrackParams,
|
||||||
|
UsageLimitExceededError,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
import { getTrackFeatureDeductions } from "@/internal/balances/track/utils/getFeatureDeductions.js";
|
import { getTrackFeatureDeductions } from "@/internal/balances/track/utils/getFeatureDeductions.js";
|
||||||
@@ -94,7 +95,10 @@ export const runCheckWithTrackV2 = async ({
|
|||||||
checkData.evaluationApiBalance = trackedBalance ?? undefined;
|
checkData.evaluationApiBalance = trackedBalance ?? undefined;
|
||||||
trackBalances = response.balances;
|
trackBalances = response.balances;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof InsufficientBalanceError) {
|
if (
|
||||||
|
error instanceof InsufficientBalanceError ||
|
||||||
|
error instanceof UsageLimitExceededError
|
||||||
|
) {
|
||||||
allowed = false;
|
allowed = false;
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -41,13 +41,20 @@ export const runRedisFinalizeLockV2 = async ({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { updates, rolloverUpdates, modifiedCusEntIdsByFeatureId } =
|
const {
|
||||||
redisResult;
|
updates,
|
||||||
|
rolloverUpdates,
|
||||||
|
modifiedCusEntIdsByFeatureId,
|
||||||
|
usageWindowUpdates,
|
||||||
|
} = redisResult;
|
||||||
const modifiedCusEntIds = deductionUpdatesToModifiedIds({ updates });
|
const modifiedCusEntIds = deductionUpdatesToModifiedIds({ updates });
|
||||||
const rolloverIds = Object.keys(rolloverUpdates);
|
const rolloverIds = Object.keys(rolloverUpdates);
|
||||||
|
|
||||||
if (modifiedCusEntIds.length > 0 || rolloverIds.length > 0) {
|
if (
|
||||||
|
modifiedCusEntIds.length > 0 ||
|
||||||
|
rolloverIds.length > 0 ||
|
||||||
|
usageWindowUpdates.length > 0
|
||||||
|
) {
|
||||||
globalSyncBatchingManagerV3.addSyncItem({
|
globalSyncBatchingManagerV3.addSyncItem({
|
||||||
customerId: receipt.customer_id,
|
customerId: receipt.customer_id,
|
||||||
orgId: ctx.org.id,
|
orgId: ctx.org.id,
|
||||||
@@ -57,6 +64,7 @@ export const runRedisFinalizeLockV2 = async ({
|
|||||||
region: currentRegion,
|
region: currentRegion,
|
||||||
entityId: receipt.entity_id ?? undefined,
|
entityId: receipt.entity_id ?? undefined,
|
||||||
modifiedCusEntIdsByFeatureId,
|
modifiedCusEntIdsByFeatureId,
|
||||||
|
usageWindowUpdates,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ApiVersion, TrackParams, TrackResponseV3 } from "@autumn/shared";
|
import type { ApiVersion, TrackParams, TrackResponseV3 } from "@autumn/shared";
|
||||||
import { withRedisFailOpen } from "@/external/redis/utils/withRedisFailOpen.js";
|
import { withRedisFailOpen } from "@/external/redis/utils/withRedisFailOpen.js";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
|
import { isFullSubjectGateRejection } from "@/internal/customers/repos/getFullSubject/getFullSubjectGate.js";
|
||||||
import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
|
import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
|
||||||
import type { FeatureDeduction } from "../utils/types/featureDeduction.js";
|
import type { FeatureDeduction } from "../utils/types/featureDeduction.js";
|
||||||
import { runTrackV2 } from "./runTrackV2.js";
|
import { runTrackV2 } from "./runTrackV2.js";
|
||||||
@@ -38,6 +39,7 @@ export const runTrackWithRollout = async ({
|
|||||||
featureDeductions,
|
featureDeductions,
|
||||||
apiVersion,
|
apiVersion,
|
||||||
}),
|
}),
|
||||||
|
alsoFailOpen: isFullSubjectGateRejection,
|
||||||
fallback: async (error) => {
|
fallback: async (error) => {
|
||||||
const queuedResponse = await queueTrack({ ctx, body });
|
const queuedResponse = await queueTrack({ ctx, body });
|
||||||
if (queuedResponse) return queuedResponse;
|
if (queuedResponse) return queuedResponse;
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
type TrackParams,
|
type TrackParams,
|
||||||
type TrackResponseV3,
|
type TrackResponseV3,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
|
||||||
import { RedisUnavailableError } from "@/external/redis/utils/errors.js";
|
import { RedisUnavailableError } from "@/external/redis/utils/errors.js";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
|
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
|
||||||
import {
|
import {
|
||||||
RedisDeductionError,
|
RedisDeductionError,
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
import type {
|
import type {
|
||||||
FullSubject,
|
FullSubject,
|
||||||
TrackDeduction,
|
TrackDeduction,
|
||||||
TrackParams,
|
TrackParams,
|
||||||
TrackResponseV3,
|
TrackResponseV3,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { tryCatch } from "@autumn/shared";
|
import { tryCatch } from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
import { globalEventBatchingManager } from "@/internal/balances/events/EventBatchingManager.js";
|
import { globalEventBatchingManager } from "@/internal/balances/events/EventBatchingManager.js";
|
||||||
|
import {
|
||||||
|
buildEventInfo,
|
||||||
|
initEvent,
|
||||||
|
} from "@/internal/balances/events/initEvent.js";
|
||||||
import { resolveInternalProductIdForEvent } from "@/internal/balances/events/resolveInternalProductIdForEvent.js";
|
import { resolveInternalProductIdForEvent } from "@/internal/balances/events/resolveInternalProductIdForEvent.js";
|
||||||
import {
|
import {
|
||||||
buildEventInfo,
|
deductionToTrackResponseV2,
|
||||||
initEvent,
|
executeRedisDeductionV2,
|
||||||
} from "@/internal/balances/events/initEvent.js";
|
projectMutationLogsToTrackDeductionsV2,
|
||||||
import {
|
|
||||||
deductionToTrackResponseV2,
|
|
||||||
executeRedisDeductionV2,
|
|
||||||
projectMutationLogsToTrackDeductionsV2,
|
|
||||||
} from "@/internal/balances/utils/deductionV2/index.js";
|
} from "@/internal/balances/utils/deductionV2/index.js";
|
||||||
import { globalSyncBatchingManagerV3 } from "@/internal/balances/utils/sync/SyncBatchingManagerV3.js";
|
import { globalSyncBatchingManagerV3 } from "@/internal/balances/utils/sync/SyncBatchingManagerV3.js";
|
||||||
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
|
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
|
||||||
import type { RolloverUpdate } from "../../utils/types/rolloverUpdate.js";
|
import type { RolloverUpdate } from "../../utils/types/rolloverUpdate.js";
|
||||||
|
import type { UsageWindowUpdate } from "../../utils/types/usageWindowUpdate.js";
|
||||||
import { buildAiCreditCostProperty } from "../utils/buildAiCreditCostProperty.js";
|
import { buildAiCreditCostProperty } from "../utils/buildAiCreditCostProperty.js";
|
||||||
import { handleRedisTrackErrorV3 } from "./handleRedisTrackErrorV3.js";
|
import { handleRedisTrackErrorV3 } from "./handleRedisTrackErrorV3.js";
|
||||||
|
|
||||||
@@ -29,18 +30,25 @@ const queueSyncItem = ({
|
|||||||
fullSubject,
|
fullSubject,
|
||||||
rolloverUpdates,
|
rolloverUpdates,
|
||||||
modifiedCusEntIdsByFeatureId,
|
modifiedCusEntIdsByFeatureId,
|
||||||
|
usageWindowUpdates,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
body: TrackParams;
|
body: TrackParams;
|
||||||
fullSubject: FullSubject;
|
fullSubject: FullSubject;
|
||||||
rolloverUpdates: Record<string, RolloverUpdate>;
|
rolloverUpdates: Record<string, RolloverUpdate>;
|
||||||
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
||||||
|
usageWindowUpdates?: UsageWindowUpdate[];
|
||||||
}): void => {
|
}): void => {
|
||||||
const cusEntIds = Object.values(modifiedCusEntIdsByFeatureId).flat();
|
const cusEntIds = Object.values(modifiedCusEntIdsByFeatureId).flat();
|
||||||
const rolloverIds = Object.keys(rolloverUpdates);
|
const rolloverIds = Object.keys(rolloverUpdates);
|
||||||
|
|
||||||
if (cusEntIds.length === 0 && rolloverIds.length === 0) return;
|
if (
|
||||||
|
cusEntIds.length === 0 &&
|
||||||
|
rolloverIds.length === 0 &&
|
||||||
|
(usageWindowUpdates?.length ?? 0) === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
globalSyncBatchingManagerV3.addSyncItem({
|
globalSyncBatchingManagerV3.addSyncItem({
|
||||||
customerId: body.customer_id,
|
customerId: body.customer_id,
|
||||||
@@ -50,6 +58,7 @@ const queueSyncItem = ({
|
|||||||
rolloverIds,
|
rolloverIds,
|
||||||
entityId: fullSubject.entityId,
|
entityId: fullSubject.entityId,
|
||||||
modifiedCusEntIdsByFeatureId,
|
modifiedCusEntIdsByFeatureId,
|
||||||
|
usageWindowUpdates,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,6 +138,7 @@ export const runRedisTrackV3 = async ({
|
|||||||
rolloverUpdates,
|
rolloverUpdates,
|
||||||
modifiedCusEntIdsByFeatureId,
|
modifiedCusEntIdsByFeatureId,
|
||||||
mutationLogs,
|
mutationLogs,
|
||||||
|
usageWindowUpdates,
|
||||||
} = result;
|
} = result;
|
||||||
|
|
||||||
queueSyncItem({
|
queueSyncItem({
|
||||||
@@ -137,6 +147,7 @@ export const runRedisTrackV3 = async ({
|
|||||||
fullSubject: updatedFullSubject,
|
fullSubject: updatedFullSubject,
|
||||||
rolloverUpdates,
|
rolloverUpdates,
|
||||||
modifiedCusEntIdsByFeatureId,
|
modifiedCusEntIdsByFeatureId,
|
||||||
|
usageWindowUpdates,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deductions = projectMutationLogsToTrackDeductionsV2({
|
const deductions = projectMutationLogsToTrackDeductionsV2({
|
||||||
|
|||||||
@@ -67,11 +67,16 @@ export const updateRemainingV2 = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rolloverUpdates, modifiedCusEntIdsByFeatureId } = result;
|
const { rolloverUpdates, modifiedCusEntIdsByFeatureId, usageWindowUpdates } =
|
||||||
|
result;
|
||||||
const cusEntIds = Object.values(modifiedCusEntIdsByFeatureId).flat();
|
const cusEntIds = Object.values(modifiedCusEntIdsByFeatureId).flat();
|
||||||
const rolloverIds = Object.keys(rolloverUpdates);
|
const rolloverIds = Object.keys(rolloverUpdates);
|
||||||
|
|
||||||
if (cusEntIds.length > 0 || rolloverIds.length > 0) {
|
if (
|
||||||
|
cusEntIds.length > 0 ||
|
||||||
|
rolloverIds.length > 0 ||
|
||||||
|
usageWindowUpdates.length > 0
|
||||||
|
) {
|
||||||
await syncItemV4({
|
await syncItemV4({
|
||||||
ctx,
|
ctx,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -82,6 +87,7 @@ export const updateRemainingV2 = async ({
|
|||||||
rolloverIds,
|
rolloverIds,
|
||||||
entityId: fullSubject.entityId,
|
entityId: fullSubject.entityId,
|
||||||
modifiedCusEntIdsByFeatureId,
|
modifiedCusEntIdsByFeatureId,
|
||||||
|
usageWindowUpdates,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,11 +111,16 @@ export const updateUsageV2 = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rolloverUpdates, modifiedCusEntIdsByFeatureId } = result;
|
const { rolloverUpdates, modifiedCusEntIdsByFeatureId, usageWindowUpdates } =
|
||||||
|
result;
|
||||||
const cusEntIds = Object.values(modifiedCusEntIdsByFeatureId).flat();
|
const cusEntIds = Object.values(modifiedCusEntIdsByFeatureId).flat();
|
||||||
const rolloverIds = Object.keys(rolloverUpdates);
|
const rolloverIds = Object.keys(rolloverUpdates);
|
||||||
|
|
||||||
if (cusEntIds.length > 0 || rolloverIds.length > 0) {
|
if (
|
||||||
|
cusEntIds.length > 0 ||
|
||||||
|
rolloverIds.length > 0 ||
|
||||||
|
usageWindowUpdates.length > 0
|
||||||
|
) {
|
||||||
await syncItemV4({
|
await syncItemV4({
|
||||||
ctx,
|
ctx,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -126,6 +131,7 @@ export const updateUsageV2 = async ({
|
|||||||
rolloverIds,
|
rolloverIds,
|
||||||
entityId: fullSubject.entityId,
|
entityId: fullSubject.entityId,
|
||||||
modifiedCusEntIdsByFeatureId,
|
modifiedCusEntIdsByFeatureId,
|
||||||
|
usageWindowUpdates,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FullSubject, UsageWindow } from "@autumn/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the in-flight subject's customer-scoped usage-window counters from
|
||||||
|
* the deduction result, so usage_limit_used (webhooks, API responses built
|
||||||
|
* from this subject) reflects the deduction. Sibling of
|
||||||
|
* applyDeductionUpdateToFullSubject / applyRolloverUpdatesToFullSubject.
|
||||||
|
*
|
||||||
|
* The Lua result carries ALL scopes; the subject keeps its own scope only
|
||||||
|
* (entity subjects hold just their entity's rows).
|
||||||
|
*/
|
||||||
|
export const applyUsageWindowUpdatesToFullSubject = ({
|
||||||
|
fullSubject,
|
||||||
|
usageWindowsByFeatureId,
|
||||||
|
}: {
|
||||||
|
fullSubject: FullSubject;
|
||||||
|
usageWindowsByFeatureId: Record<string, UsageWindow[]> | null | undefined;
|
||||||
|
}): void => {
|
||||||
|
if (!usageWindowsByFeatureId) return;
|
||||||
|
|
||||||
|
const updatedFeatureIds = new Set(Object.keys(usageWindowsByFeatureId));
|
||||||
|
const updatedWindows = Object.values(usageWindowsByFeatureId)
|
||||||
|
.flat()
|
||||||
|
.filter((usageWindow) =>
|
||||||
|
fullSubject.internalEntityId
|
||||||
|
? usageWindow.internal_entity_id === fullSubject.internalEntityId
|
||||||
|
: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
fullSubject.usage_windows = [
|
||||||
|
...(fullSubject.usage_windows ?? []).filter(
|
||||||
|
(usageWindow) => !updatedFeatureIds.has(usageWindow.feature_id),
|
||||||
|
),
|
||||||
|
...updatedWindows,
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -117,6 +117,7 @@ export const executePostgresDeductionV2 = async ({
|
|||||||
fullSubject,
|
fullSubject,
|
||||||
deduction,
|
deduction,
|
||||||
options: resolvedOptions,
|
options: resolvedOptions,
|
||||||
|
now: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (customerEntitlements.length === 0 || unlimitedFeatureIds.length > 0) {
|
if (customerEntitlements.length === 0 || unlimitedFeatureIds.length > 0) {
|
||||||
@@ -147,6 +148,9 @@ export const executePostgresDeductionV2 = async ({
|
|||||||
sql`SELECT * FROM deduct_from_cus_ents(
|
sql`SELECT * FROM deduct_from_cus_ents(
|
||||||
${JSON.stringify({
|
${JSON.stringify({
|
||||||
sorted_entitlements: customerEntitlementDeductions,
|
sorted_entitlements: customerEntitlementDeductions,
|
||||||
|
// No usage_window_limits here: the hard usage cap is enforced only on the
|
||||||
|
// Redis/Lua path, so this Postgres fallback intentionally fails open
|
||||||
|
// (availability over strict cap enforcement during a Redis outage).
|
||||||
spend_limit_by_feature_id: spendLimitByFeatureId ?? null,
|
spend_limit_by_feature_id: spendLimitByFeatureId ?? null,
|
||||||
usage_based_cus_ent_ids_by_feature_id:
|
usage_based_cus_ent_ids_by_feature_id:
|
||||||
usageBasedCusEntIdsByFeatureId ?? null,
|
usageBasedCusEntIdsByFeatureId ?? null,
|
||||||
@@ -318,11 +322,11 @@ export const executePostgresDeductionV2 = async ({
|
|||||||
|
|
||||||
const deductionResult = resolvedOptions.paidAllocated
|
const deductionResult = resolvedOptions.paidAllocated
|
||||||
? await withLock({
|
? await withLock({
|
||||||
lockKey: `lock:deduction:${org.id}:${env}:${customerId}`,
|
lockKey: `lock:deduction:${org.id}:${env}:${customerId}`,
|
||||||
ttlMs: 60000,
|
ttlMs: 60000,
|
||||||
errorMessage: `Deduction for paid feature ${deductions[0]?.feature?.name} already in progress for customer ${customerId}.`,
|
errorMessage: `Deduction for paid feature ${deductions[0]?.feature?.name} already in progress for customer ${customerId}.`,
|
||||||
fn: executeDeduction,
|
fn: executeDeduction,
|
||||||
})
|
})
|
||||||
: await executeDeduction();
|
: await executeDeduction();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
type FullCusEntWithFullCusProduct,
|
type FullCusEntWithFullCusProduct,
|
||||||
type FullSubject,
|
type FullSubject,
|
||||||
fullSubjectToFullCustomer,
|
fullSubjectToFullCustomer,
|
||||||
|
notNullish,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { Redis } from "ioredis";
|
import type { Redis } from "ioredis";
|
||||||
import { currentRegion } from "@/external/redis/initRedis.js";
|
import { currentRegion } from "@/external/redis/initRedis.js";
|
||||||
@@ -16,6 +17,7 @@ import { createAllocatedInvoice } from "@/internal/balances/utils/allocatedInvoi
|
|||||||
import { saveLockReceiptV2 } from "@/internal/balances/utils/lockV2/saveLockReceiptV2.js";
|
import { saveLockReceiptV2 } from "@/internal/balances/utils/lockV2/saveLockReceiptV2.js";
|
||||||
import { buildDeductFromSubjectBalancesKeys } from "@/internal/customers/cache/fullSubject/builders/buildDeductFromSubjectBalancesKeys.js";
|
import { buildDeductFromSubjectBalancesKeys } from "@/internal/customers/cache/fullSubject/builders/buildDeductFromSubjectBalancesKeys.js";
|
||||||
import { buildFullSubjectKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectKey.js";
|
import { buildFullSubjectKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectKey.js";
|
||||||
|
import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js";
|
||||||
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
|
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
|
||||||
import type { DeductionOptions } from "../types/deductionTypes.js";
|
import type { DeductionOptions } from "../types/deductionTypes.js";
|
||||||
import type { DeductionUpdate } from "../types/deductionUpdate.js";
|
import type { DeductionUpdate } from "../types/deductionUpdate.js";
|
||||||
@@ -27,8 +29,11 @@ import {
|
|||||||
} from "../types/redisDeductionError.js";
|
} from "../types/redisDeductionError.js";
|
||||||
import type { LuaDeductionResult } from "../types/redisDeductionResult.js";
|
import type { LuaDeductionResult } from "../types/redisDeductionResult.js";
|
||||||
import type { RolloverUpdate } from "../types/rolloverUpdate.js";
|
import type { RolloverUpdate } from "../types/rolloverUpdate.js";
|
||||||
|
import type { UsageWindowMutation } from "../types/usageWindowMutation.js";
|
||||||
|
import type { UsageWindowUpdate } from "../types/usageWindowUpdate.js";
|
||||||
import { applyDeductionUpdateToFullSubject } from "./applyDeductionUpdateToFullSubject.js";
|
import { applyDeductionUpdateToFullSubject } from "./applyDeductionUpdateToFullSubject.js";
|
||||||
import { applyRolloverUpdatesToFullSubject } from "./applyRolloverUpdatesToFullSubject.js";
|
import { applyRolloverUpdatesToFullSubject } from "./applyRolloverUpdatesToFullSubject.js";
|
||||||
|
import { applyUsageWindowUpdatesToFullSubject } from "./applyUsageWindowUpdatesToFullSubject.js";
|
||||||
import { buildUnlimitedPlanMutationLog } from "./buildUnlimitedPlanMutationLog.js";
|
import { buildUnlimitedPlanMutationLog } from "./buildUnlimitedPlanMutationLog.js";
|
||||||
import { logDeductionUpdatesV2 } from "./logDeductionUpdatesV2.js";
|
import { logDeductionUpdatesV2 } from "./logDeductionUpdatesV2.js";
|
||||||
import { mutationLogsToFeaturesV2 } from "./mutationLogsToFeaturesV2.js";
|
import { mutationLogsToFeaturesV2 } from "./mutationLogsToFeaturesV2.js";
|
||||||
@@ -60,6 +65,8 @@ export const executeRedisDeductionV2 = async ({
|
|||||||
rolloverUpdates: Record<string, RolloverUpdate>;
|
rolloverUpdates: Record<string, RolloverUpdate>;
|
||||||
mutationLogs: MutationLogItem[];
|
mutationLogs: MutationLogItem[];
|
||||||
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
||||||
|
usageWindowUpdates: UsageWindowUpdate[];
|
||||||
|
usageWindowMutations: UsageWindowMutation[];
|
||||||
}> => {
|
}> => {
|
||||||
const { org, env } = ctx;
|
const { org, env } = ctx;
|
||||||
const oldFullSubject = structuredClone(fullSubject);
|
const oldFullSubject = structuredClone(fullSubject);
|
||||||
@@ -95,7 +102,11 @@ export const executeRedisDeductionV2 = async ({
|
|||||||
let allUpdates: Record<string, DeductionUpdate> = {};
|
let allUpdates: Record<string, DeductionUpdate> = {};
|
||||||
let allRolloverUpdates: Record<string, RolloverUpdate> = {};
|
let allRolloverUpdates: Record<string, RolloverUpdate> = {};
|
||||||
let allMutationLogs: MutationLogItem[] = [];
|
let allMutationLogs: MutationLogItem[] = [];
|
||||||
|
let allUsageWindowMutations: UsageWindowMutation[] = [];
|
||||||
const allModifiedCusEntIdsByFeatureId: Record<string, string[]> = {};
|
const allModifiedCusEntIdsByFeatureId: Record<string, string[]> = {};
|
||||||
|
// Keyed by feature id: each Lua result carries the COMPLETE post-deduction
|
||||||
|
// counter array per capped feature, so last write wins across deductions.
|
||||||
|
const allUsageWindowUpdates: Record<string, UsageWindowUpdate> = {};
|
||||||
|
|
||||||
const customerId = fullSubject.customerId;
|
const customerId = fullSubject.customerId;
|
||||||
const routingKey = buildFullSubjectKey({
|
const routingKey = buildFullSubjectKey({
|
||||||
@@ -105,6 +116,10 @@ export const executeRedisDeductionV2 = async ({
|
|||||||
entityId: fullSubject.entityId,
|
entityId: fullSubject.entityId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// One timestamp for the whole operation: the resolver keys windows from it
|
||||||
|
// and Lua receives the same value, so they never disagree on the window.
|
||||||
|
const usageWindowNow = Date.now();
|
||||||
|
|
||||||
for (const deduction of deductions) {
|
for (const deduction of deductions) {
|
||||||
const {
|
const {
|
||||||
feature,
|
feature,
|
||||||
@@ -118,6 +133,8 @@ export const executeRedisDeductionV2 = async ({
|
|||||||
customerEntitlementDeductions,
|
customerEntitlementDeductions,
|
||||||
spendLimitByFeatureId,
|
spendLimitByFeatureId,
|
||||||
usageBasedCusEntIdsByFeatureId,
|
usageBasedCusEntIdsByFeatureId,
|
||||||
|
usageWindowLimits,
|
||||||
|
usageWindowFeatureIds,
|
||||||
rollovers,
|
rollovers,
|
||||||
customerEntitlements,
|
customerEntitlements,
|
||||||
unlimitedFeatureIds,
|
unlimitedFeatureIds,
|
||||||
@@ -128,6 +145,7 @@ export const executeRedisDeductionV2 = async ({
|
|||||||
fullSubject,
|
fullSubject,
|
||||||
deduction,
|
deduction,
|
||||||
options,
|
options,
|
||||||
|
now: usageWindowNow,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (unlimitedFeatureIds.length > 0) {
|
if (unlimitedFeatureIds.length > 0) {
|
||||||
@@ -172,8 +190,17 @@ export const executeRedisDeductionV2 = async ({
|
|||||||
idempotencyKey: idempotencyRedisKey,
|
idempotencyKey: idempotencyRedisKey,
|
||||||
customerEntitlementDeductions,
|
customerEntitlementDeductions,
|
||||||
fallbackFeatureId: feature.id,
|
fallbackFeatureId: feature.id,
|
||||||
|
usageWindowFeatureIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Usage windows are enforced/incremented only for real positive
|
||||||
|
// consumption, never for target_balance set-downs or granted-balance edits.
|
||||||
|
const isConsumption =
|
||||||
|
notNullish(toDeduct) &&
|
||||||
|
(toDeduct as number) > 0 &&
|
||||||
|
!notNullish(targetBalance) &&
|
||||||
|
!options.alterGrantedBalance;
|
||||||
|
|
||||||
const luaParams = {
|
const luaParams = {
|
||||||
org_id: org.id,
|
org_id: org.id,
|
||||||
env,
|
env,
|
||||||
@@ -183,6 +210,10 @@ export const executeRedisDeductionV2 = async ({
|
|||||||
spend_limit_by_feature_id: spendLimitByFeatureId ?? null,
|
spend_limit_by_feature_id: spendLimitByFeatureId ?? null,
|
||||||
usage_based_cus_ent_ids_by_feature_id:
|
usage_based_cus_ent_ids_by_feature_id:
|
||||||
usageBasedCusEntIdsByFeatureId ?? null,
|
usageBasedCusEntIdsByFeatureId ?? null,
|
||||||
|
usage_window_limits: usageWindowLimits ?? null,
|
||||||
|
usage_window_now: usageWindowNow,
|
||||||
|
usage_window_ttl_seconds: FULL_SUBJECT_CACHE_TTL_SECONDS,
|
||||||
|
is_consumption: isConsumption,
|
||||||
amount_to_deduct: toDeduct ?? null,
|
amount_to_deduct: toDeduct ?? null,
|
||||||
target_balance: targetBalance ?? null,
|
target_balance: targetBalance ?? null,
|
||||||
target_entity_id: entityId || null,
|
target_entity_id: entityId || null,
|
||||||
@@ -234,6 +265,7 @@ export const executeRedisDeductionV2 = async ({
|
|||||||
throw new RedisDeductionError({
|
throw new RedisDeductionError({
|
||||||
message: `Redis deduction failed: ${resultJson.error}`,
|
message: `Redis deduction failed: ${resultJson.error}`,
|
||||||
code: resultJson.error as RedisDeductionErrorCode,
|
code: resultJson.error as RedisDeductionErrorCode,
|
||||||
|
featureId: resultJson.feature_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +273,11 @@ export const executeRedisDeductionV2 = async ({
|
|||||||
const mutationLogs = Array.isArray(resultJson.mutation_logs)
|
const mutationLogs = Array.isArray(resultJson.mutation_logs)
|
||||||
? resultJson.mutation_logs
|
? resultJson.mutation_logs
|
||||||
: [];
|
: [];
|
||||||
|
const usageWindowMutations = Array.isArray(
|
||||||
|
resultJson.usage_window_mutations,
|
||||||
|
)
|
||||||
|
? resultJson.usage_window_mutations
|
||||||
|
: [];
|
||||||
const modifiedCustomerEntitlementIds = Array.isArray(
|
const modifiedCustomerEntitlementIds = Array.isArray(
|
||||||
resultJson.modified_customer_entitlement_ids,
|
resultJson.modified_customer_entitlement_ids,
|
||||||
)
|
)
|
||||||
@@ -257,6 +294,21 @@ export const executeRedisDeductionV2 = async ({
|
|||||||
allUpdates = { ...allUpdates, ...updates };
|
allUpdates = { ...allUpdates, ...updates };
|
||||||
allRolloverUpdates = { ...allRolloverUpdates, ...rollover_updates };
|
allRolloverUpdates = { ...allRolloverUpdates, ...rollover_updates };
|
||||||
allMutationLogs = [...allMutationLogs, ...mutationLogs];
|
allMutationLogs = [...allMutationLogs, ...mutationLogs];
|
||||||
|
allUsageWindowMutations = [
|
||||||
|
...allUsageWindowMutations,
|
||||||
|
...usageWindowMutations,
|
||||||
|
];
|
||||||
|
// Typed handoff for the PG mirror; empty arrays kept (prune-to-empty
|
||||||
|
// must still full-replace).
|
||||||
|
for (const [featureId, usageWindows] of Object.entries(
|
||||||
|
resultJson.usage_windows_by_feature_id ?? {},
|
||||||
|
)) {
|
||||||
|
allUsageWindowUpdates[featureId] = {
|
||||||
|
internal_customer_id: fullSubject.internalCustomerId,
|
||||||
|
feature_id: featureId,
|
||||||
|
usage_windows: usageWindows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const syncState = normalizeDeductionSyncStateV2({
|
const syncState = normalizeDeductionSyncStateV2({
|
||||||
customerEntitlements,
|
customerEntitlements,
|
||||||
@@ -281,6 +333,11 @@ export const executeRedisDeductionV2 = async ({
|
|||||||
rolloverUpdates: rollover_updates,
|
rolloverUpdates: rollover_updates,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
applyUsageWindowUpdatesToFullSubject({
|
||||||
|
fullSubject,
|
||||||
|
usageWindowsByFeatureId: resultJson.usage_windows_by_feature_id,
|
||||||
|
});
|
||||||
|
|
||||||
for (const customerEntitlementId of Object.keys(updates)) {
|
for (const customerEntitlementId of Object.keys(updates)) {
|
||||||
const update = updates[customerEntitlementId];
|
const update = updates[customerEntitlementId];
|
||||||
const customerEntitlement = customerEntitlements.find(
|
const customerEntitlement = customerEntitlements.find(
|
||||||
@@ -355,5 +412,7 @@ export const executeRedisDeductionV2 = async ({
|
|||||||
rolloverUpdates: allRolloverUpdates,
|
rolloverUpdates: allRolloverUpdates,
|
||||||
mutationLogs: allMutationLogs,
|
mutationLogs: allMutationLogs,
|
||||||
modifiedCusEntIdsByFeatureId: allModifiedCusEntIdsByFeatureId,
|
modifiedCusEntIdsByFeatureId: allModifiedCusEntIdsByFeatureId,
|
||||||
|
usageWindowUpdates: Object.values(allUsageWindowUpdates),
|
||||||
|
usageWindowMutations: allUsageWindowMutations,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
import {
|
import {
|
||||||
AllowanceType,
|
AllowanceType,
|
||||||
cusEntToStartingBalance,
|
cusEntToStartingBalance,
|
||||||
|
ErrCode,
|
||||||
type FullCusEntWithFullCusProduct,
|
type FullCusEntWithFullCusProduct,
|
||||||
type FullSubject,
|
type FullSubject,
|
||||||
fullSubjectToCustomerEntitlements,
|
fullSubjectToCustomerEntitlements,
|
||||||
fullSubjectToOverageAllowedByFeatureId,
|
fullSubjectToOverageAllowedByFeatureId,
|
||||||
fullSubjectToSpendLimitByFeatureId,
|
fullSubjectToSpendLimitByFeatureId,
|
||||||
fullSubjectToUsageBasedCusEntsByFeatureId,
|
fullSubjectToUsageBasedCusEntsByFeatureId,
|
||||||
|
fullSubjectToUsageWindowLimits,
|
||||||
getMaxOverage,
|
getMaxOverage,
|
||||||
getRelevantFeatures,
|
getRelevantFeatures,
|
||||||
isAllocatedCustomerEntitlement,
|
isAllocatedCustomerEntitlement,
|
||||||
isFreeCustomerEntitlement,
|
isFreeCustomerEntitlement,
|
||||||
notNullish,
|
notNullish,
|
||||||
orgToInStatuses,
|
orgToInStatuses,
|
||||||
|
RecaseError,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js";
|
import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js";
|
||||||
import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
|
import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
|
||||||
|
import { generateId } from "@/utils/genUtils.js";
|
||||||
import { computeCreditCosts } from "../deduction/computeCreditCosts.js";
|
import { computeCreditCosts } from "../deduction/computeCreditCosts.js";
|
||||||
import type {
|
import type {
|
||||||
CustomerEntitlementDeduction,
|
CustomerEntitlementDeduction,
|
||||||
@@ -34,11 +38,15 @@ export const prepareFeatureDeductionV2 = ({
|
|||||||
fullSubject,
|
fullSubject,
|
||||||
deduction,
|
deduction,
|
||||||
options = {},
|
options = {},
|
||||||
|
now,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
fullSubject: FullSubject;
|
fullSubject: FullSubject;
|
||||||
deduction: FeatureDeduction;
|
deduction: FeatureDeduction;
|
||||||
options?: DeductionOptions;
|
options?: DeductionOptions;
|
||||||
|
// Single timestamp shared with the Lua param so the resolved window key and
|
||||||
|
// the script agree on which window a boundary-crossing request lands in.
|
||||||
|
now: number;
|
||||||
}): PreparedFeatureDeduction => {
|
}): PreparedFeatureDeduction => {
|
||||||
const { org, env } = ctx;
|
const { org, env } = ctx;
|
||||||
const { feature, lock, targetBalance } = deduction;
|
const { feature, lock, targetBalance } = deduction;
|
||||||
@@ -108,6 +116,41 @@ export const prepareFeatureDeductionV2 = ({
|
|||||||
fullSubject,
|
fullSubject,
|
||||||
featureIds: effectiveFeatureIds,
|
featureIds: effectiveFeatureIds,
|
||||||
});
|
});
|
||||||
|
// Resolve windows against the full relevant set (incl credit-system parents)
|
||||||
|
// even under set_usage, so a parent-feature cap can't be bypassed by set_usage
|
||||||
|
// on a member feature.
|
||||||
|
const windowFeatureIds = notNullish(targetBalance)
|
||||||
|
? getRelevantFeatures({
|
||||||
|
features: ctx.features,
|
||||||
|
featureId: feature.id,
|
||||||
|
}).map((candidate) => candidate.id)
|
||||||
|
: effectiveFeatureIds;
|
||||||
|
const usageWindowLimits = fullSubjectToUsageWindowLimits({
|
||||||
|
fullSubject,
|
||||||
|
featureIds: windowFeatureIds,
|
||||||
|
features: ctx.features,
|
||||||
|
now,
|
||||||
|
inStatuses: orgToInStatuses({ org }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Counters are customer-scoped: a null anchor only means calendar-aligned
|
||||||
|
// bounds with no provenance, not an unenforceable cap.
|
||||||
|
for (const windowLimit of usageWindowLimits) {
|
||||||
|
windowLimit.new_window_id = generateId("uw");
|
||||||
|
if (windowLimit.anchor_customer_entitlement_id === null) {
|
||||||
|
ctx.logger.warn(
|
||||||
|
`usage window for feature ${windowLimit.feature_id} has no anchor entitlement; using calendar-aligned bounds with no provenance.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// set_usage carries no window provenance, so it would silently bypass the hard
|
||||||
|
// cap; reject it when the feature has an enforced usage window.
|
||||||
|
if (notNullish(targetBalance) && usageWindowLimits.length > 0) {
|
||||||
|
throw new RecaseError({
|
||||||
|
message: `Cannot set usage for feature ${feature.id}: it has an active usage limit. Remove or adjust the limit, or record usage normally instead of using set_usage.`,
|
||||||
|
code: ErrCode.SetUsageNotAllowedWithUsageLimit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const nativeUsageAllowedFeatureIds = new Set(
|
const nativeUsageAllowedFeatureIds = new Set(
|
||||||
customerEntitlements
|
customerEntitlements
|
||||||
@@ -211,6 +254,12 @@ export const prepareFeatureDeductionV2 = ({
|
|||||||
Object.keys(usageBasedCusEntIdsByFeatureId).length > 0
|
Object.keys(usageBasedCusEntIdsByFeatureId).length > 0
|
||||||
? usageBasedCusEntIdsByFeatureId
|
? usageBasedCusEntIdsByFeatureId
|
||||||
: undefined,
|
: undefined,
|
||||||
|
usageWindowLimits:
|
||||||
|
usageWindowLimits.length > 0 ? usageWindowLimits : undefined,
|
||||||
|
usageWindowFeatureIds:
|
||||||
|
usageWindowLimits.length > 0
|
||||||
|
? [...new Set(usageWindowLimits.map((limit) => limit.feature_id))]
|
||||||
|
: undefined,
|
||||||
rollovers: sortedRollovers.map((rollover) => ({
|
rollovers: sortedRollovers.map((rollover) => ({
|
||||||
id: rollover.id,
|
id: rollover.id,
|
||||||
credit_cost: rollover.credit_cost,
|
credit_cost: rollover.credit_cost,
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
-- - balance: number
|
-- - balance: number
|
||||||
-- - usage: number
|
-- - usage: number
|
||||||
-- - entities: jsonb (the full entities object)
|
-- - entities: jsonb (the full entities object)
|
||||||
|
-- usage_window_updates: array of objects with:
|
||||||
|
-- - internal_customer_id: string
|
||||||
|
-- - feature_id: string
|
||||||
|
-- - usage_windows: jsonb array of DbUsageWindow rows (the COMPLETE set for
|
||||||
|
-- that customer+feature; Redis is authoritative and prunes closed
|
||||||
|
-- windows, so rows are full-replaced per customer+feature)
|
||||||
--
|
--
|
||||||
-- Returns JSONB with:
|
-- Returns JSONB with:
|
||||||
-- updates: object mapping customer_entitlement_id -> { balance, adjustment, entities }
|
-- updates: object mapping customer_entitlement_id -> { balance, adjustment, entities }
|
||||||
@@ -32,7 +38,8 @@ AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
customer_entitlement_updates jsonb := params->'customer_entitlement_updates';
|
customer_entitlement_updates jsonb := params->'customer_entitlement_updates';
|
||||||
rollover_updates_param jsonb := params->'rollover_updates';
|
rollover_updates_param jsonb := params->'rollover_updates';
|
||||||
|
usage_window_updates_param jsonb := params->'usage_window_updates';
|
||||||
|
|
||||||
ent_obj jsonb;
|
ent_obj jsonb;
|
||||||
ent_id text;
|
ent_id text;
|
||||||
ent_balance numeric;
|
ent_balance numeric;
|
||||||
@@ -41,6 +48,11 @@ DECLARE
|
|||||||
ent_next_reset_at bigint;
|
ent_next_reset_at bigint;
|
||||||
ent_entity_count int;
|
ent_entity_count int;
|
||||||
ent_cache_version int;
|
ent_cache_version int;
|
||||||
|
|
||||||
|
uw_obj jsonb;
|
||||||
|
uw_internal_customer_id text;
|
||||||
|
uw_feature_id text;
|
||||||
|
uw_windows jsonb;
|
||||||
|
|
||||||
db_next_reset_at bigint;
|
db_next_reset_at bigint;
|
||||||
db_entity_count int;
|
db_entity_count int;
|
||||||
@@ -134,15 +146,13 @@ BEGIN
|
|||||||
ent_id, ent_cache_version, db_cache_version;
|
ent_id, ent_cache_version, db_cache_version;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- Update the customer_entitlement row directly
|
|
||||||
UPDATE customer_entitlements ce
|
UPDATE customer_entitlements ce
|
||||||
SET
|
SET
|
||||||
balance = COALESCE(ent_balance, ce.balance),
|
balance = COALESCE(ent_balance, ce.balance),
|
||||||
adjustment = COALESCE(ent_adjustment, ce.adjustment),
|
adjustment = COALESCE(ent_adjustment, ce.adjustment),
|
||||||
entities = COALESCE(ent_entities, ce.entities)
|
entities = COALESCE(ent_entities, ce.entities)
|
||||||
WHERE ce.id = ent_id;
|
WHERE ce.id = ent_id;
|
||||||
|
|
||||||
-- Track update
|
|
||||||
IF FOUND THEN
|
IF FOUND THEN
|
||||||
updates_json := jsonb_set(
|
updates_json := jsonb_set(
|
||||||
updates_json,
|
updates_json,
|
||||||
@@ -191,6 +201,75 @@ BEGIN
|
|||||||
END LOOP;
|
END LOOP;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 4: Mirror usage-window counters (race-safe upsert)
|
||||||
|
-- ============================================================================
|
||||||
|
-- ONE mutable row per (customer, feature, entity scope); bounds roll in
|
||||||
|
-- place. Upsert on the scope key (never on id) so concurrent creates can't
|
||||||
|
-- abort, with an updated_at guard so older snapshots never clobber newer.
|
||||||
|
IF usage_window_updates_param IS NOT NULL THEN
|
||||||
|
FOR uw_obj IN SELECT * FROM jsonb_array_elements(usage_window_updates_param)
|
||||||
|
LOOP
|
||||||
|
uw_internal_customer_id := uw_obj->>'internal_customer_id';
|
||||||
|
uw_feature_id := uw_obj->>'feature_id';
|
||||||
|
uw_windows := uw_obj->'usage_windows';
|
||||||
|
|
||||||
|
IF uw_internal_customer_id IS NOT NULL
|
||||||
|
AND uw_feature_id IS NOT NULL
|
||||||
|
AND uw_windows IS NOT NULL
|
||||||
|
AND uw_windows != 'null'::jsonb THEN
|
||||||
|
IF jsonb_typeof(uw_windows) != 'array' THEN
|
||||||
|
uw_windows := '[]'::jsonb;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO usage_windows (
|
||||||
|
id, internal_customer_id, internal_entity_id, feature_id,
|
||||||
|
internal_feature_id, anchor_customer_entitlement_id,
|
||||||
|
window_start_at, window_end_at, usage, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
w->>'id',
|
||||||
|
uw_internal_customer_id,
|
||||||
|
w->>'internal_entity_id',
|
||||||
|
uw_feature_id,
|
||||||
|
w->>'internal_feature_id',
|
||||||
|
CASE
|
||||||
|
WHEN w->>'anchor_customer_entitlement_id' IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM customer_entitlements ce
|
||||||
|
WHERE ce.id = w->>'anchor_customer_entitlement_id'
|
||||||
|
)
|
||||||
|
THEN w->>'anchor_customer_entitlement_id'
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
(w->>'window_start_at')::numeric,
|
||||||
|
(w->>'window_end_at')::numeric,
|
||||||
|
(w->>'usage')::numeric,
|
||||||
|
(w->>'updated_at')::numeric
|
||||||
|
FROM jsonb_array_elements(uw_windows) AS w
|
||||||
|
WHERE w->>'id' IS NOT NULL
|
||||||
|
AND w->>'internal_feature_id' IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM features f
|
||||||
|
WHERE f.internal_id = w->>'internal_feature_id'
|
||||||
|
)
|
||||||
|
ON CONFLICT (
|
||||||
|
internal_customer_id, internal_feature_id,
|
||||||
|
COALESCE(internal_entity_id, '')
|
||||||
|
)
|
||||||
|
DO UPDATE SET
|
||||||
|
usage = EXCLUDED.usage,
|
||||||
|
updated_at = EXCLUDED.updated_at,
|
||||||
|
window_start_at = EXCLUDED.window_start_at,
|
||||||
|
window_end_at = EXCLUDED.window_end_at,
|
||||||
|
feature_id = EXCLUDED.feature_id,
|
||||||
|
anchor_customer_entitlement_id =
|
||||||
|
EXCLUDED.anchor_customer_entitlement_id
|
||||||
|
WHERE EXCLUDED.updated_at >= usage_windows.updated_at;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
|
||||||
RETURN jsonb_build_object(
|
RETURN jsonb_build_object(
|
||||||
'updates', updates_json,
|
'updates', updates_json,
|
||||||
'rollover_updates', rollover_updates_json
|
'rollover_updates', rollover_updates_json
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { logger } from "@/external/logtail/logtailUtils.js";
|
|||||||
import { currentRegion } from "@/external/redis/initRedis.js";
|
import { currentRegion } from "@/external/redis/initRedis.js";
|
||||||
import { JobName } from "@/queue/JobName.js";
|
import { JobName } from "@/queue/JobName.js";
|
||||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||||
|
import type { UsageWindowUpdate } from "../types/usageWindowUpdate.js";
|
||||||
|
|
||||||
interface CustomerBatchContext {
|
interface CustomerBatchContext {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
@@ -14,6 +15,10 @@ interface CustomerBatchContext {
|
|||||||
rolloverIds: Set<string>;
|
rolloverIds: Set<string>;
|
||||||
entityId?: string;
|
entityId?: string;
|
||||||
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
||||||
|
// Counter SNAPSHOTS keyed by capped feature: each deduction returns the
|
||||||
|
// complete post-deduction array, so merging across batched items is
|
||||||
|
// last-write-wins (unlike cusEnt/rollover ids, which accumulate).
|
||||||
|
usageWindowUpdatesByFeatureId: Record<string, UsageWindowUpdate>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CustomerBatch {
|
interface CustomerBatch {
|
||||||
@@ -33,6 +38,7 @@ export type QueueSyncV4Payload = {
|
|||||||
rolloverIds: string[];
|
rolloverIds: string[];
|
||||||
entityId?: string;
|
entityId?: string;
|
||||||
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
||||||
|
usageWindowUpdates?: UsageWindowUpdate[];
|
||||||
};
|
};
|
||||||
messageGroupId?: string;
|
messageGroupId?: string;
|
||||||
messageDeduplicationId: string;
|
messageDeduplicationId: string;
|
||||||
@@ -78,6 +84,7 @@ export class SyncBatchingManagerV3 {
|
|||||||
region,
|
region,
|
||||||
entityId,
|
entityId,
|
||||||
modifiedCusEntIdsByFeatureId,
|
modifiedCusEntIdsByFeatureId,
|
||||||
|
usageWindowUpdates,
|
||||||
}: {
|
}: {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -87,6 +94,7 @@ export class SyncBatchingManagerV3 {
|
|||||||
region?: string;
|
region?: string;
|
||||||
entityId?: string;
|
entityId?: string;
|
||||||
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
||||||
|
usageWindowUpdates?: UsageWindowUpdate[];
|
||||||
}): void {
|
}): void {
|
||||||
const batchKey = this.buildBatchKey({ orgId, env, customerId });
|
const batchKey = this.buildBatchKey({ orgId, env, customerId });
|
||||||
let batch = this.customerBatches.get(batchKey);
|
let batch = this.customerBatches.get(batchKey);
|
||||||
@@ -112,6 +120,12 @@ export class SyncBatchingManagerV3 {
|
|||||||
batch.context.modifiedCusEntIdsByFeatureId[featureId].push(...ids);
|
batch.context.modifiedCusEntIdsByFeatureId[featureId].push(...ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const usageWindowUpdate of usageWindowUpdates ?? []) {
|
||||||
|
batch.context.usageWindowUpdatesByFeatureId[
|
||||||
|
usageWindowUpdate.feature_id
|
||||||
|
] = usageWindowUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
const totalSize =
|
const totalSize =
|
||||||
batch.context.cusEntIds.size + batch.context.rolloverIds.size;
|
batch.context.cusEntIds.size + batch.context.rolloverIds.size;
|
||||||
if (totalSize >= this.MAX_BATCH_SIZE) {
|
if (totalSize >= this.MAX_BATCH_SIZE) {
|
||||||
@@ -177,6 +191,7 @@ export class SyncBatchingManagerV3 {
|
|||||||
cusEntIds: new Set(),
|
cusEntIds: new Set(),
|
||||||
rolloverIds: new Set(),
|
rolloverIds: new Set(),
|
||||||
modifiedCusEntIdsByFeatureId: {},
|
modifiedCusEntIdsByFeatureId: {},
|
||||||
|
usageWindowUpdatesByFeatureId: {},
|
||||||
},
|
},
|
||||||
timer: null,
|
timer: null,
|
||||||
};
|
};
|
||||||
@@ -231,7 +246,13 @@ export class SyncBatchingManagerV3 {
|
|||||||
this.customerBatches.delete(batchKey);
|
this.customerBatches.delete(batchKey);
|
||||||
|
|
||||||
const { context } = batch;
|
const { context } = batch;
|
||||||
if (context.cusEntIds.size === 0 && context.rolloverIds.size === 0) return;
|
if (
|
||||||
|
context.cusEntIds.size === 0 &&
|
||||||
|
context.rolloverIds.size === 0 &&
|
||||||
|
Object.keys(context.usageWindowUpdatesByFeatureId).length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.queueSyncJob({ context });
|
await this.queueSyncJob({ context });
|
||||||
}
|
}
|
||||||
@@ -247,10 +268,12 @@ export class SyncBatchingManagerV3 {
|
|||||||
context,
|
context,
|
||||||
cusEntIds,
|
cusEntIds,
|
||||||
rolloverIds,
|
rolloverIds,
|
||||||
|
usageWindowUpdates,
|
||||||
}: {
|
}: {
|
||||||
context: CustomerBatchContext;
|
context: CustomerBatchContext;
|
||||||
cusEntIds: string[];
|
cusEntIds: string[];
|
||||||
rolloverIds: string[];
|
rolloverIds: string[];
|
||||||
|
usageWindowUpdates: UsageWindowUpdate[];
|
||||||
}): string {
|
}): string {
|
||||||
const dedupBucket = Math.floor(Date.now() / this.DEDUP_BUCKET_MS);
|
const dedupBucket = Math.floor(Date.now() / this.DEDUP_BUCKET_MS);
|
||||||
const dedupKey = JSON.stringify({
|
const dedupKey = JSON.stringify({
|
||||||
@@ -260,6 +283,10 @@ export class SyncBatchingManagerV3 {
|
|||||||
customerId: context.customerId,
|
customerId: context.customerId,
|
||||||
cusEntIds,
|
cusEntIds,
|
||||||
rolloverIds,
|
rolloverIds,
|
||||||
|
// Snapshots ride the payload (cusEnt balances are re-read at consume
|
||||||
|
// time, counters are not), so a newer snapshot must never be dropped
|
||||||
|
// as a duplicate of an older one within the bucket.
|
||||||
|
usageWindowUpdates,
|
||||||
dedupBucket,
|
dedupBucket,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -273,10 +300,14 @@ export class SyncBatchingManagerV3 {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const cusEntIds = Array.from(context.cusEntIds).sort();
|
const cusEntIds = Array.from(context.cusEntIds).sort();
|
||||||
const rolloverIds = Array.from(context.rolloverIds).sort();
|
const rolloverIds = Array.from(context.rolloverIds).sort();
|
||||||
|
const usageWindowUpdates = Object.values(
|
||||||
|
context.usageWindowUpdatesByFeatureId,
|
||||||
|
);
|
||||||
const messageDeduplicationId = this.buildDeduplicationId({
|
const messageDeduplicationId = this.buildDeduplicationId({
|
||||||
context,
|
context,
|
||||||
cusEntIds,
|
cusEntIds,
|
||||||
rolloverIds,
|
rolloverIds,
|
||||||
|
usageWindowUpdates,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -292,13 +323,14 @@ export class SyncBatchingManagerV3 {
|
|||||||
rolloverIds,
|
rolloverIds,
|
||||||
entityId: context.entityId,
|
entityId: context.entityId,
|
||||||
modifiedCusEntIdsByFeatureId: context.modifiedCusEntIdsByFeatureId,
|
modifiedCusEntIdsByFeatureId: context.modifiedCusEntIdsByFeatureId,
|
||||||
|
usageWindowUpdates,
|
||||||
},
|
},
|
||||||
// messageGroupId: `sync-v4:${context.orgId}:${context.env}:${context.customerId}`,
|
// messageGroupId: `sync-v4:${context.orgId}:${context.env}:${context.customerId}`,
|
||||||
messageDeduplicationId,
|
messageDeduplicationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[SyncV4] Queued sync for ${context.customerId}, ${cusEntIds.length} entitlements, ${rolloverIds.length} rollovers`,
|
`[SyncV4] Queued sync for ${context.customerId}, ${cusEntIds.length} entitlements, ${rolloverIds.length} rollovers, ${usageWindowUpdates.length} usage windows`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
|||||||
import { getCachedFeatureBalance } from "@/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.js";
|
import { getCachedFeatureBalance } from "@/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.js";
|
||||||
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js";
|
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js";
|
||||||
import { globalRefreshEntityAggregateBatchingManager } from "../refreshEntityAggregate/RefreshEntityAggregateBatchingManager";
|
import { globalRefreshEntityAggregateBatchingManager } from "../refreshEntityAggregate/RefreshEntityAggregateBatchingManager";
|
||||||
|
import type { UsageWindowUpdate } from "../types/usageWindowUpdate.js";
|
||||||
import { logSyncItem } from "./logs/logSyncItem";
|
import { logSyncItem } from "./logs/logSyncItem";
|
||||||
|
|
||||||
const SYNC_CONFLICT_CODES = {
|
const SYNC_CONFLICT_CODES = {
|
||||||
@@ -68,6 +69,10 @@ interface SyncItemV4 {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
rolloverIds?: string[];
|
rolloverIds?: string[];
|
||||||
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
||||||
|
/** Post-deduction counter snapshots handed straight from the Lua result
|
||||||
|
* (no Redis re-read); mirrored to the customer-scoped usage_windows table
|
||||||
|
* via full-replace per (customer, feature). */
|
||||||
|
usageWindowUpdates?: UsageWindowUpdate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncEntry {
|
export interface SyncEntry {
|
||||||
@@ -113,12 +118,17 @@ export const syncItemV4 = async ({
|
|||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
payload: SyncItemV4;
|
payload: SyncItemV4;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
const { customerId, entityId, rolloverIds, modifiedCusEntIdsByFeatureId } =
|
const {
|
||||||
payload;
|
customerId,
|
||||||
|
entityId,
|
||||||
|
rolloverIds,
|
||||||
|
modifiedCusEntIdsByFeatureId,
|
||||||
|
usageWindowUpdates,
|
||||||
|
} = payload;
|
||||||
const { db } = ctx;
|
const { db } = ctx;
|
||||||
|
|
||||||
// Read targeted balance hashes
|
// Read targeted balance hashes
|
||||||
const allSubjectBalances: SubjectBalance[] = [];
|
let allSubjectBalances: SubjectBalance[] = [];
|
||||||
for (const [featureId, customerEntitlementIds] of Object.entries(
|
for (const [featureId, customerEntitlementIds] of Object.entries(
|
||||||
modifiedCusEntIdsByFeatureId,
|
modifiedCusEntIdsByFeatureId,
|
||||||
)) {
|
)) {
|
||||||
@@ -131,6 +141,9 @@ export const syncItemV4 = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (outcome.kind !== "ok") {
|
if (outcome.kind !== "ok") {
|
||||||
|
ctx.logger.warn(
|
||||||
|
`[SYNC V4] (${customerId}) Cache miss for feature ${featureId}; skipping this feature only.`,
|
||||||
|
);
|
||||||
logSyncItem({
|
logSyncItem({
|
||||||
ctx,
|
ctx,
|
||||||
result: {
|
result: {
|
||||||
@@ -139,7 +152,11 @@ export const syncItemV4 = async ({
|
|||||||
feature: featureId,
|
feature: featureId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
// A miss (e.g. an invalidation racing the batch) drops the BALANCE
|
||||||
|
// sync wholesale, but usage-window snapshots ride in the payload and
|
||||||
|
// need no cache read -- they must still land.
|
||||||
|
allSubjectBalances = [];
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
allSubjectBalances.push(...outcome.value.balances);
|
allSubjectBalances.push(...outcome.value.balances);
|
||||||
@@ -169,7 +186,16 @@ export const syncItemV4 = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0 && rolloverEntries.length === 0) {
|
// Customer-scoped usage-window counters arrive pre-built from the deduction
|
||||||
|
// result (same atomic Lua execution that incremented them) -- no Redis
|
||||||
|
// re-read here. Full-replaced per (customer, feature) by the SQL function.
|
||||||
|
const usageWindowEntries: UsageWindowUpdate[] = usageWindowUpdates ?? [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
entries.length === 0 &&
|
||||||
|
rolloverEntries.length === 0 &&
|
||||||
|
usageWindowEntries.length === 0
|
||||||
|
) {
|
||||||
logSyncItem({ ctx, result: { kind: "skipped", reason: "no_entries" } });
|
logSyncItem({ ctx, result: { kind: "skipped", reason: "no_entries" } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -179,6 +205,7 @@ export const syncItemV4 = async ({
|
|||||||
sql`SELECT * FROM sync_balances_v2(${JSON.stringify({
|
sql`SELECT * FROM sync_balances_v2(${JSON.stringify({
|
||||||
customer_entitlement_updates: entries,
|
customer_entitlement_updates: entries,
|
||||||
rollover_updates: rolloverEntries,
|
rollover_updates: rolloverEntries,
|
||||||
|
usage_window_updates: usageWindowEntries,
|
||||||
})}::jsonb)`,
|
})}::jsonb)`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
CustomerEntitlementFilters,
|
CustomerEntitlementFilters,
|
||||||
DbSpendLimit,
|
DbSpendLimit,
|
||||||
FullCusEntWithFullCusProduct,
|
FullCusEntWithFullCusProduct,
|
||||||
|
UsageWindowLimit,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
|
|
||||||
/** Behavior options for deduction */
|
/** Behavior options for deduction */
|
||||||
@@ -42,6 +43,12 @@ export type PreparedFeatureDeduction = {
|
|||||||
customerEntitlementDeductions: CustomerEntitlementDeduction[];
|
customerEntitlementDeductions: CustomerEntitlementDeduction[];
|
||||||
spendLimitByFeatureId?: Record<string, DbSpendLimit>;
|
spendLimitByFeatureId?: Record<string, DbSpendLimit>;
|
||||||
usageBasedCusEntIdsByFeatureId?: Record<string, string[]>;
|
usageBasedCusEntIdsByFeatureId?: Record<string, string[]>;
|
||||||
|
// Resolved windowed usage-limit caps, enforced inside the deduction script.
|
||||||
|
usageWindowLimits?: UsageWindowLimit[];
|
||||||
|
// Distinct capped feature ids: their balance hashes carry the
|
||||||
|
// `_usage_windows` counter field, so their keys must be declared in KEYS[]
|
||||||
|
// even when no deduction entry references them.
|
||||||
|
usageWindowFeatureIds?: string[];
|
||||||
// rolloverIds: string[];
|
// rolloverIds: string[];
|
||||||
rollovers: RolloverDeduction[];
|
rollovers: RolloverDeduction[];
|
||||||
unlimitedFeatureIds: string[];
|
unlimitedFeatureIds: string[];
|
||||||
|
|||||||
@@ -23,17 +23,21 @@ export const FALLBACK_ERROR_CODES = [
|
|||||||
/** Error thrown by Redis deduction operations */
|
/** Error thrown by Redis deduction operations */
|
||||||
export class RedisDeductionError extends Error {
|
export class RedisDeductionError extends Error {
|
||||||
code: RedisDeductionErrorCode;
|
code: RedisDeductionErrorCode;
|
||||||
|
featureId?: string;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
message,
|
message,
|
||||||
code,
|
code,
|
||||||
|
featureId,
|
||||||
}: {
|
}: {
|
||||||
message: string;
|
message: string;
|
||||||
code: RedisDeductionErrorCode;
|
code: RedisDeductionErrorCode;
|
||||||
|
featureId?: string;
|
||||||
}) {
|
}) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "RedisDeductionError";
|
this.name = "RedisDeductionError";
|
||||||
this.code = code;
|
this.code = code;
|
||||||
|
this.featureId = featureId;
|
||||||
}
|
}
|
||||||
|
|
||||||
isRedisUnavailable(): boolean {
|
isRedisUnavailable(): boolean {
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
|
import type { UsageWindow } from "@autumn/shared";
|
||||||
import type { DeductionUpdate } from "./deductionUpdate.js";
|
import type { DeductionUpdate } from "./deductionUpdate.js";
|
||||||
import type { MutationLogItem } from "./mutationLogItem.js";
|
import type { MutationLogItem } from "./mutationLogItem.js";
|
||||||
import type { RolloverUpdate } from "./rolloverUpdate.js";
|
import type { RolloverUpdate } from "./rolloverUpdate.js";
|
||||||
|
import type { UsageWindowMutation } from "./usageWindowMutation.js";
|
||||||
|
|
||||||
export interface LuaDeductionResult {
|
export interface LuaDeductionResult {
|
||||||
updates: Record<string, DeductionUpdate>;
|
updates: Record<string, DeductionUpdate>;
|
||||||
rollover_updates: Record<string, RolloverUpdate>;
|
rollover_updates: Record<string, RolloverUpdate>;
|
||||||
modified_customer_entitlement_ids: string[];
|
modified_customer_entitlement_ids: string[];
|
||||||
mutation_logs: MutationLogItem[];
|
mutation_logs: MutationLogItem[];
|
||||||
|
/** Post-deduction COUNTER ROWS per capped feature (usage amounts; mirrors
|
||||||
|
* the usage_windows table) -- not the limits config, which goes IN via
|
||||||
|
* usage_window_limits. Null when no usage windows were enforced. */
|
||||||
|
usage_windows_by_feature_id?: Record<string, UsageWindow[]> | null;
|
||||||
|
/** Per-window deltas applied by this deduction (sibling stream of
|
||||||
|
* mutation_logs). */
|
||||||
|
usage_window_mutations?: UsageWindowMutation[];
|
||||||
remaining: number;
|
remaining: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
feature_id?: string;
|
feature_id?: string;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* One usage-window counter mutation from a deduction (sibling of
|
||||||
|
* MutationLogItem, kept as its own stream): which window row moved and by how
|
||||||
|
* much. The row is identified by its stored id plus the logical key
|
||||||
|
* (feature + window + entity scope); `usage_delta` is in the limit's native
|
||||||
|
* unit (tracked units for metered dims, credits for balance dims).
|
||||||
|
*/
|
||||||
|
export interface UsageWindowMutation {
|
||||||
|
usage_window_id: string | null;
|
||||||
|
feature_id: string;
|
||||||
|
internal_entity_id: string | null;
|
||||||
|
window_start_at: number;
|
||||||
|
usage_delta: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { UsageWindow } from "@autumn/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-deduction usage-window counter state for one capped feature, handed
|
||||||
|
* down from the Lua result through the deduction flow to syncItemV4 (sibling
|
||||||
|
* of DeductionUpdate / RolloverUpdate).
|
||||||
|
*
|
||||||
|
* Deliberately a SNAPSHOT, not a MutationLog-style delta: the deduction
|
||||||
|
* script is atomic and the Postgres sync full-replaces rows per (customer,
|
||||||
|
* feature), so the complete `usage_windows` array IS the update. An empty
|
||||||
|
* array is meaningful (all windows pruned/closed) and still full-replaces.
|
||||||
|
* Matches the `usage_window_updates` jsonb param of sync_balances_v2 1:1.
|
||||||
|
*/
|
||||||
|
export interface UsageWindowUpdate {
|
||||||
|
internal_customer_id: string;
|
||||||
|
feature_id: string;
|
||||||
|
usage_windows: UsageWindow[];
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ export const finalizeLineItems = ({
|
|||||||
autumnBillingPlan: AutumnBillingPlan;
|
autumnBillingPlan: AutumnBillingPlan;
|
||||||
customLineItems?: CustomLineItem[];
|
customLineItems?: CustomLineItem[];
|
||||||
}): LineItem[] => {
|
}): LineItem[] => {
|
||||||
|
if (billingContext.skipBillingChanges) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
billingContext.requestedProrationBehavior === "none" &&
|
billingContext.requestedProrationBehavior === "none" &&
|
||||||
!billingContext.anchorResetRefund?.noPartialRefund
|
!billingContext.anchorResetRefund?.noPartialRefund
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
atmnToStripeAmount,
|
||||||
InternalError,
|
InternalError,
|
||||||
type LineItemContext,
|
type LineItemContext,
|
||||||
type Organization,
|
type Organization,
|
||||||
@@ -76,7 +77,7 @@ export const updateOneOffTieredItems = ({
|
|||||||
product_data: {
|
product_data: {
|
||||||
name: lineItem.description,
|
name: lineItem.description,
|
||||||
},
|
},
|
||||||
unit_amount: Math.round(lineItem.amount * 100),
|
unit_amount: atmnToStripeAmount({ amount: lineItem.amount, currency }),
|
||||||
currency,
|
currency,
|
||||||
},
|
},
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ const toStripeCreateInvoiceItemParams = ({
|
|||||||
|
|
||||||
amount: shouldUsePriceData
|
amount: shouldUsePriceData
|
||||||
? undefined
|
? undefined
|
||||||
: atmnToStripeAmount({ amount: lineAmount }),
|
: atmnToStripeAmount({ amount: lineAmount, currency }),
|
||||||
|
|
||||||
price_data: shouldUsePriceData
|
price_data: shouldUsePriceData
|
||||||
? {
|
? {
|
||||||
unit_amount: atmnToStripeAmount({ amount: lineAmount }),
|
unit_amount: atmnToStripeAmount({ amount: lineAmount, currency }),
|
||||||
currency,
|
currency,
|
||||||
product: stripeProductId,
|
product: stripeProductId,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ const toStripeAddLineParams = ({
|
|||||||
description,
|
description,
|
||||||
amount: shouldUsePriceData
|
amount: shouldUsePriceData
|
||||||
? undefined
|
? undefined
|
||||||
: atmnToStripeAmount({ amount: lineAmount }),
|
: atmnToStripeAmount({ amount: lineAmount, currency }),
|
||||||
price_data: shouldUsePriceData
|
price_data: shouldUsePriceData
|
||||||
? {
|
? {
|
||||||
unit_amount: atmnToStripeAmount({ amount: lineAmount }),
|
unit_amount: atmnToStripeAmount({ amount: lineAmount, currency }),
|
||||||
currency,
|
currency,
|
||||||
product: stripeProductId,
|
product: stripeProductId,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ const toStripeSubscriptionAddInvoiceItem = ({
|
|||||||
price_data: {
|
price_data: {
|
||||||
currency: context.currency,
|
currency: context.currency,
|
||||||
product: stripeProductId,
|
product: stripeProductId,
|
||||||
unit_amount: atmnToStripeAmount({ amount: amountAfterDiscounts }),
|
unit_amount: atmnToStripeAmount({
|
||||||
|
amount: amountAfterDiscounts,
|
||||||
|
currency: context.currency,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
period: context.effectivePeriod
|
period: context.effectivePeriod
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type {
|
import {
|
||||||
BillingContext,
|
type BillingContext,
|
||||||
StripeDiscountWithCoupon,
|
orgToCurrency,
|
||||||
StripeInvoiceAction,
|
type StripeDiscountWithCoupon,
|
||||||
|
type StripeInvoiceAction,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import {
|
import {
|
||||||
type PayInvoiceResult,
|
type PayInvoiceResult,
|
||||||
@@ -92,12 +93,17 @@ export const createInvoiceForBilling = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const wantsAutoTax = shouldEnableStripeAutomaticTax({ ctx, billingContext });
|
const wantsAutoTax = shouldEnableStripeAutomaticTax({ ctx, billingContext });
|
||||||
|
const stripeSubId = options.skipSubscriptionLink
|
||||||
|
? undefined
|
||||||
|
: billingContext.stripeSubscription?.id;
|
||||||
|
|
||||||
const draftInvoice = await createStripeInvoice({
|
const draftInvoice = await createStripeInvoice({
|
||||||
stripeCli,
|
stripeCli,
|
||||||
stripeCusId: billingContext.stripeCustomer?.id ?? "none",
|
stripeCusId: billingContext.stripeCustomer?.id ?? "none",
|
||||||
stripeSubId: options.skipSubscriptionLink
|
stripeSubId,
|
||||||
? undefined
|
// Subscription-linked invoices inherit currency from the subscription;
|
||||||
: billingContext.stripeSubscription?.id,
|
// standalone invoices default to the account currency, not the org's.
|
||||||
|
currency: stripeSubId ? undefined : orgToCurrency({ org: ctx.org }),
|
||||||
collectionMethod,
|
collectionMethod,
|
||||||
daysUntilDue: invoiceMode?.daysUntilDue,
|
daysUntilDue: invoiceMode?.daysUntilDue,
|
||||||
footer: invoiceMode?.footer,
|
footer: invoiceMode?.footer,
|
||||||
|
|||||||
@@ -78,5 +78,21 @@ export const setupBillingCycleAnchor = ({
|
|||||||
// Billing cycle anchor = trial ends at if exists
|
// Billing cycle anchor = trial ends at if exists
|
||||||
if (newIsTrialing) return trialContext?.trialEndsAt ?? "now";
|
if (newIsTrialing) return trialContext?.trialEndsAt ?? "now";
|
||||||
|
|
||||||
return secondsToMs(stripeSubscription?.billing_cycle_anchor) ?? "now";
|
const stripeAnchorMs = secondsToMs(stripeSubscription?.billing_cycle_anchor);
|
||||||
|
|
||||||
|
// Stripe stores the anchor in SECONDS (rounded either way from the ms
|
||||||
|
// instant it was created). When it's the same instant the current product
|
||||||
|
// started, prefer the ms-precision starts_at so cycles recomputed across
|
||||||
|
// updates/upgrades don't drift sub-second (which would churn
|
||||||
|
// next_reset_at and spuriously move cycle-keyed state like usage windows).
|
||||||
|
const startsAtMs = customerProduct?.starts_at;
|
||||||
|
if (
|
||||||
|
stripeAnchorMs != null &&
|
||||||
|
startsAtMs != null &&
|
||||||
|
Math.abs(startsAtMs - stripeAnchorMs) < 1000
|
||||||
|
) {
|
||||||
|
return startsAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stripeAnchorMs ?? "now";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export const chargeRowToRefundLineItem = ({
|
|||||||
context,
|
context,
|
||||||
stripePriceId: chargeRow.stripe_price_id ?? undefined,
|
stripePriceId: chargeRow.stripe_price_id ?? undefined,
|
||||||
stripeProductId: chargeRow.stripe_product_id ?? undefined,
|
stripeProductId: chargeRow.stripe_product_id ?? undefined,
|
||||||
chargeImmediately: true,
|
chargeImmediately: chargeRow.invoice_id === null ? false : true,
|
||||||
prorated: true,
|
prorated: true,
|
||||||
discounts:
|
discounts:
|
||||||
(chargeRow.discounts as InvoiceLineItemDiscount[] | null)?.map((d) => ({
|
(chargeRow.discounts as InvoiceLineItemDiscount[] | null)?.map((d) => ({
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ export const getRefundLineItems = ({
|
|||||||
billingContext,
|
billingContext,
|
||||||
priceFilters,
|
priceFilters,
|
||||||
billingCycleAnchorMsOverride,
|
billingCycleAnchorMsOverride,
|
||||||
|
includeCatalogFallback = true,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
customerProduct: FullCusProduct;
|
customerProduct: FullCusProduct;
|
||||||
billingContext: BillingContext;
|
billingContext: BillingContext;
|
||||||
priceFilters?: { excludeOneOffPrices?: boolean };
|
priceFilters?: { excludeOneOffPrices?: boolean };
|
||||||
billingCycleAnchorMsOverride?: BillingContext["billingCycleAnchorMs"];
|
billingCycleAnchorMsOverride?: BillingContext["billingCycleAnchorMs"];
|
||||||
|
includeCatalogFallback?: boolean;
|
||||||
}): LineItem[] => {
|
}): LineItem[] => {
|
||||||
const {
|
const {
|
||||||
lineItems: matchedCredits,
|
lineItems: matchedCredits,
|
||||||
@@ -27,6 +29,7 @@ export const getRefundLineItems = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (allPricesResolved) return matchedCredits;
|
if (allPricesResolved) return matchedCredits;
|
||||||
|
if (!includeCatalogFallback) return matchedCredits;
|
||||||
|
|
||||||
const catalogCredits = customerProductToLineItems({
|
const catalogCredits = customerProductToLineItems({
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const getRefundLineItemsForPrice = ({
|
|||||||
ctx,
|
ctx,
|
||||||
customerProduct,
|
customerProduct,
|
||||||
billingContext,
|
billingContext,
|
||||||
|
includeCatalogFallback: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const matchedRefundsForPrice = matchedRefundLineItems.filter(
|
const matchedRefundsForPrice = matchedRefundLineItems.filter(
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const invoiceCreditFromStoredLineItems = ({
|
|||||||
row.customer_product_ids.length > 0 &&
|
row.customer_product_ids.length > 0 &&
|
||||||
row.effective_period_start != null &&
|
row.effective_period_start != null &&
|
||||||
row.effective_period_end != null &&
|
row.effective_period_end != null &&
|
||||||
row.effective_period_start < now &&
|
row.effective_period_start <= now &&
|
||||||
row.effective_period_end > now,
|
row.effective_period_end > now,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ export const invoiceCreditFromStoredLineItems = ({
|
|||||||
r.customer_product_ids.includes(customerProduct.id) &&
|
r.customer_product_ids.includes(customerProduct.id) &&
|
||||||
r.effective_period_end != null &&
|
r.effective_period_end != null &&
|
||||||
r.effective_period_start != null &&
|
r.effective_period_start != null &&
|
||||||
r.effective_period_start < now &&
|
r.effective_period_start <= now &&
|
||||||
r.effective_period_end > now,
|
r.effective_period_end > now,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { FullSubject, NormalizedFullSubject } from "@autumn/shared";
|
||||||
|
import type { UsageWindowRoll } from "./computeUsageWindowRolls.js";
|
||||||
|
|
||||||
|
/** Mirrors persisted rolls onto the in-flight subject (and its normalized
|
||||||
|
* twin), so this request's response already shows the rolled state. */
|
||||||
|
export const applyUsageWindowRollsToSubject = ({
|
||||||
|
fullSubject,
|
||||||
|
normalized,
|
||||||
|
rolls,
|
||||||
|
now,
|
||||||
|
}: {
|
||||||
|
fullSubject: FullSubject;
|
||||||
|
normalized?: NormalizedFullSubject;
|
||||||
|
rolls: UsageWindowRoll[];
|
||||||
|
now: number;
|
||||||
|
}): void => {
|
||||||
|
const rollsById = new Map(rolls.map((roll) => [roll.id, roll]));
|
||||||
|
|
||||||
|
const apply = (windows: FullSubject["usage_windows"]) => {
|
||||||
|
for (const usageWindow of windows ?? []) {
|
||||||
|
const roll = rollsById.get(usageWindow.id);
|
||||||
|
if (!roll) continue;
|
||||||
|
if (roll.zero_usage) usageWindow.usage = 0;
|
||||||
|
usageWindow.window_start_at = roll.window_start_at;
|
||||||
|
usageWindow.window_end_at = roll.window_end_at;
|
||||||
|
usageWindow.anchor_customer_entitlement_id =
|
||||||
|
roll.anchor_customer_entitlement_id;
|
||||||
|
usageWindow.updated_at = now;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
apply(fullSubject.usage_windows);
|
||||||
|
if (normalized) apply(normalized.usage_windows);
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
findUsageWindowLimitByWindow,
|
||||||
|
type UsageWindow,
|
||||||
|
type UsageWindowLimit,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
|
||||||
|
export type UsageWindowRoll = {
|
||||||
|
id: string;
|
||||||
|
feature_id: string;
|
||||||
|
internal_entity_id: string | null;
|
||||||
|
/** True when the stored window closed: the count must zero. A roll never
|
||||||
|
* writes a count otherwise, so it can't clobber a concurrent deduction. */
|
||||||
|
zero_usage: boolean;
|
||||||
|
window_start_at: number;
|
||||||
|
window_end_at: number;
|
||||||
|
anchor_customer_entitlement_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides, per counter row, whether it needs rolling. A count is only valid
|
||||||
|
* within the exact window stamped on it; the anchor is provenance, so an
|
||||||
|
* anchor-only re-point keeps the count:
|
||||||
|
*
|
||||||
|
* expired | window moved | anchor moved | result
|
||||||
|
* --------+--------------+--------------+---------------------------------
|
||||||
|
* no | no | no | no roll (the common case)
|
||||||
|
* no | no | yes | re-point anchor, count kept
|
||||||
|
* no | yes | any | re-bound, count zeroed (plan change)
|
||||||
|
* yes | any | any | re-bound, count zeroed (period over)
|
||||||
|
* yes | (no limit) | -- | bounds kept, count zeroed (entity rows, v1)
|
||||||
|
*
|
||||||
|
* "Window moved" compares the row's bounds against its limit's CURRENT
|
||||||
|
* derivation (anchor ent's cycle). Entity-scoped rows have no resolvable
|
||||||
|
* limit in v1, so their bounds can't re-derive -- but an expired count must
|
||||||
|
* still zero.
|
||||||
|
*/
|
||||||
|
export const computeUsageWindowRolls = ({
|
||||||
|
usageWindows,
|
||||||
|
limits,
|
||||||
|
now,
|
||||||
|
}: {
|
||||||
|
usageWindows: UsageWindow[];
|
||||||
|
limits: UsageWindowLimit[];
|
||||||
|
now: number;
|
||||||
|
}): UsageWindowRoll[] => {
|
||||||
|
const rolls: UsageWindowRoll[] = [];
|
||||||
|
|
||||||
|
for (const usageWindow of usageWindows) {
|
||||||
|
const expired = Number(usageWindow.window_end_at) <= now;
|
||||||
|
|
||||||
|
const limit = findUsageWindowLimitByWindow({ limits, usageWindow });
|
||||||
|
|
||||||
|
const target = limit
|
||||||
|
? {
|
||||||
|
window_start_at: limit.window_start_at,
|
||||||
|
window_end_at: limit.window_end_at,
|
||||||
|
anchor_customer_entitlement_id: limit.anchor_customer_entitlement_id,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
window_start_at: Number(usageWindow.window_start_at),
|
||||||
|
window_end_at: Number(usageWindow.window_end_at),
|
||||||
|
anchor_customer_entitlement_id:
|
||||||
|
usageWindow.anchor_customer_entitlement_id ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const windowMoved =
|
||||||
|
Number(usageWindow.window_start_at) !== target.window_start_at ||
|
||||||
|
Number(usageWindow.window_end_at) !== target.window_end_at;
|
||||||
|
const anchorMoved =
|
||||||
|
(usageWindow.anchor_customer_entitlement_id ?? null) !==
|
||||||
|
target.anchor_customer_entitlement_id;
|
||||||
|
|
||||||
|
if (!expired && !windowMoved && !anchorMoved) continue;
|
||||||
|
|
||||||
|
rolls.push({
|
||||||
|
id: usageWindow.id,
|
||||||
|
feature_id: usageWindow.feature_id,
|
||||||
|
internal_entity_id: usageWindow.internal_entity_id ?? null,
|
||||||
|
// A count never survives its stamped window; an anchor-only
|
||||||
|
// re-point (e.g. an ent recreated with the same cycle) keeps it.
|
||||||
|
zero_usage: expired || windowMoved,
|
||||||
|
...target,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rolls;
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
type FullSubject,
|
||||||
|
fullSubjectToUsageWindowLimits,
|
||||||
|
type NormalizedFullSubject,
|
||||||
|
orgToInStatuses,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import * as Sentry from "@sentry/bun";
|
||||||
|
import { getDbHealth, PgHealth } from "@/db/pgHealthMonitor.js";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
|
import { usageWindowRepo } from "@/internal/customers/usageWindows/repos/index.js";
|
||||||
|
import { applyUsageWindowRollsToSubject } from "./applyUsageWindowRollsToSubject.js";
|
||||||
|
import { computeUsageWindowRolls } from "./computeUsageWindowRolls.js";
|
||||||
|
import { rollUsageWindowsCache } from "./rollUsageWindowsCache.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily ROLLS the subject's usage-window counters on every subject read:
|
||||||
|
* zero counts whose stored window closed, and re-align bounds/anchor to the
|
||||||
|
* current derivation (this is where a plan change lands in the DB). The
|
||||||
|
* decision table lives in computeUsageWindowRolls.
|
||||||
|
*
|
||||||
|
* Best-effort, like lazyResetSubjectEntitlements: reads and the deduction
|
||||||
|
* script both derive a closed count as 0 and stamp fresh bounds on write, so
|
||||||
|
* a failed roll only delays persistence. Rolls are idempotent (same target
|
||||||
|
* state), so concurrent reads converge. Returns true if any rows rolled.
|
||||||
|
*/
|
||||||
|
export const lazyResetSubjectUsageWindows = async ({
|
||||||
|
ctx,
|
||||||
|
fullSubject,
|
||||||
|
normalized,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
fullSubject: FullSubject;
|
||||||
|
normalized?: NormalizedFullSubject;
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
if (getDbHealth() === PgHealth.Degraded) return false;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const usageWindows = fullSubject.usage_windows ?? [];
|
||||||
|
if (usageWindows.length === 0) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const limits = fullSubjectToUsageWindowLimits({
|
||||||
|
fullSubject,
|
||||||
|
featureIds: [
|
||||||
|
...new Set(usageWindows.map((usageWindow) => usageWindow.feature_id)),
|
||||||
|
],
|
||||||
|
features: ctx.features,
|
||||||
|
now,
|
||||||
|
inStatuses: orgToInStatuses({ org: ctx.org }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rolls = computeUsageWindowRolls({ usageWindows, limits, now });
|
||||||
|
if (rolls.length === 0) return false;
|
||||||
|
|
||||||
|
ctx.logger.info(
|
||||||
|
`[lazyResetSubjectUsageWindows] customer: ${fullSubject.customerId}, rolling: ${rolls.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await usageWindowRepo.rollWindows({ db: ctx.db, rolls, now });
|
||||||
|
await rollUsageWindowsCache({
|
||||||
|
ctx,
|
||||||
|
customerId: fullSubject.customerId,
|
||||||
|
rolls,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
applyUsageWindowRollsToSubject({ fullSubject, normalized, rolls, now });
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
ctx.logger.error(
|
||||||
|
`[lazyResetSubjectUsageWindows] customer: ${fullSubject.customerId}, failed: ${error}`,
|
||||||
|
);
|
||||||
|
Sentry.captureException(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
|
import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js";
|
||||||
|
import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js";
|
||||||
|
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
|
||||||
|
import type { UsageWindowRoll } from "./computeUsageWindowRolls.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically patches rolled counters into each affected feature's
|
||||||
|
* '_usage_windows' field (one rollUsageWindows Lua call per feature,
|
||||||
|
* pipelined). Fire-and-forget -- reads and the deduction script both derive
|
||||||
|
* a closed window as 0, so a missed patch only delays the persisted roll.
|
||||||
|
*/
|
||||||
|
export const rollUsageWindowsCache = async ({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
rolls,
|
||||||
|
now,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
customerId: string;
|
||||||
|
rolls: UsageWindowRoll[];
|
||||||
|
now: number;
|
||||||
|
}): Promise<void> => {
|
||||||
|
if (rolls.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { org, env, redisV2 } = ctx;
|
||||||
|
|
||||||
|
const rollsByFeatureId: Record<string, UsageWindowRoll[]> = {};
|
||||||
|
for (const roll of rolls) {
|
||||||
|
const featureRolls = rollsByFeatureId[roll.feature_id] ?? [];
|
||||||
|
featureRolls.push(roll);
|
||||||
|
rollsByFeatureId[roll.feature_id] = featureRolls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipeline = redisV2.pipeline();
|
||||||
|
for (const [featureId, featureRolls] of Object.entries(rollsByFeatureId)) {
|
||||||
|
const balanceKey = buildSharedFullSubjectBalanceKey({
|
||||||
|
orgId: org.id,
|
||||||
|
env,
|
||||||
|
customerId,
|
||||||
|
featureId,
|
||||||
|
});
|
||||||
|
pipeline.rollUsageWindows(
|
||||||
|
balanceKey,
|
||||||
|
JSON.stringify({
|
||||||
|
now,
|
||||||
|
ttl_seconds: FULL_SUBJECT_CACHE_TTL_SECONDS,
|
||||||
|
rolls: featureRolls,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tryRedisWrite(() => pipeline.exec(), redisV2);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.logger.error(
|
||||||
|
`[rollUsageWindowsCache] customer=${customerId}, failed: ${error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -139,6 +139,8 @@ export const updateCustomer = async ({
|
|||||||
billingControlUpdates.auto_topups = billing_controls.auto_topups;
|
billingControlUpdates.auto_topups = billing_controls.auto_topups;
|
||||||
if (billing_controls.spend_limits !== undefined)
|
if (billing_controls.spend_limits !== undefined)
|
||||||
billingControlUpdates.spend_limits = billing_controls.spend_limits;
|
billingControlUpdates.spend_limits = billing_controls.spend_limits;
|
||||||
|
if (billing_controls.usage_limits !== undefined)
|
||||||
|
billingControlUpdates.usage_limits = billing_controls.usage_limits;
|
||||||
if (billing_controls.usage_alerts !== undefined)
|
if (billing_controls.usage_alerts !== undefined)
|
||||||
billingControlUpdates.usage_alerts = billing_controls.usage_alerts;
|
billingControlUpdates.usage_alerts = billing_controls.usage_alerts;
|
||||||
if (billing_controls.overage_allowed !== undefined)
|
if (billing_controls.overage_allowed !== undefined)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
type AttachConfig,
|
type AttachConfig,
|
||||||
type AttachFunctionResponse,
|
type AttachFunctionResponse,
|
||||||
AttachFunctionResponseSchema,
|
AttachFunctionResponseSchema,
|
||||||
|
atmnToStripeAmount,
|
||||||
isFixedPrice,
|
isFixedPrice,
|
||||||
MetadataType,
|
MetadataType,
|
||||||
priceToInvoiceAmount,
|
priceToInvoiceAmount,
|
||||||
@@ -99,7 +100,10 @@ export const handleOneOffFunction = async ({
|
|||||||
invoiceItemData = {
|
invoiceItemData = {
|
||||||
description,
|
description,
|
||||||
price_data: {
|
price_data: {
|
||||||
unit_amount: new Decimal(amount).mul(100).round().toNumber(),
|
unit_amount: atmnToStripeAmount({
|
||||||
|
amount,
|
||||||
|
currency: orgToCurrency({ org }),
|
||||||
|
}),
|
||||||
currency: orgToCurrency({ org }),
|
currency: orgToCurrency({ org }),
|
||||||
product: price.config?.stripe_product_id || product?.processor?.id,
|
product: price.config?.stripe_product_id || product?.processor?.id,
|
||||||
},
|
},
|
||||||
@@ -136,7 +140,7 @@ export const handleOneOffFunction = async ({
|
|||||||
|
|
||||||
// Skip auto_tax in invoice mode: send_invoice has no
|
// Skip auto_tax in invoice mode: send_invoice has no
|
||||||
// address-collection UI so Stripe Tax rejects.
|
// address-collection UI so Stripe Tax rejects.
|
||||||
const wantsAutoTax =
|
const wantsAutoTax =
|
||||||
!!org.config.automatic_tax &&
|
!!org.config.automatic_tax &&
|
||||||
!attachParams.invoiceOnly &&
|
!attachParams.invoiceOnly &&
|
||||||
customerHasUsableTaxLocationForStripeTax(attachParams.stripeCus);
|
customerHasUsableTaxLocationForStripeTax(attachParams.stripeCus);
|
||||||
@@ -145,12 +149,8 @@ const wantsAutoTax =
|
|||||||
customer: customer.processor.id!,
|
customer: customer.processor.id!,
|
||||||
auto_advance: false,
|
auto_advance: false,
|
||||||
currency: orgToCurrency({ org }),
|
currency: orgToCurrency({ org }),
|
||||||
discounts: rewards
|
discounts: rewards ? rewards.map((r) => ({ coupon: r.id })) : undefined,
|
||||||
? rewards.map((r) => ({ coupon: r.id }))
|
collection_method: attachParams.invoiceOnly ? "send_invoice" : undefined,
|
||||||
: undefined,
|
|
||||||
collection_method: attachParams.invoiceOnly
|
|
||||||
? "send_invoice"
|
|
||||||
: undefined,
|
|
||||||
days_until_due: attachParams.invoiceOnly ? 30 : undefined,
|
days_until_due: attachParams.invoiceOnly ? 30 : undefined,
|
||||||
...(shouldMemo ? { description: invoiceMemo } : {}),
|
...(shouldMemo ? { description: invoiceMemo } : {}),
|
||||||
...(wantsAutoTax ? { automatic_tax: { enabled: true } } : {}),
|
...(wantsAutoTax ? { automatic_tax: { enabled: true } } : {}),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
atmnToStripeAmount,
|
||||||
type BillingInterval,
|
type BillingInterval,
|
||||||
BillingType,
|
BillingType,
|
||||||
cusProductsToCusPrices,
|
cusProductsToCusPrices,
|
||||||
@@ -91,7 +92,10 @@ const getUsageInvoiceItems = async ({
|
|||||||
description,
|
description,
|
||||||
price_data: {
|
price_data: {
|
||||||
product: config.stripe_product_id!,
|
product: config.stripe_product_id!,
|
||||||
unit_amount: Math.round(amount * 100),
|
unit_amount: atmnToStripeAmount({
|
||||||
|
amount,
|
||||||
|
currency: org.default_currency || "usd",
|
||||||
|
}),
|
||||||
currency: org.default_currency || "usd",
|
currency: org.default_currency || "usd",
|
||||||
},
|
},
|
||||||
period: {
|
period: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
atmnToStripeAmount,
|
||||||
type BillingInterval,
|
type BillingInterval,
|
||||||
BillingType,
|
BillingType,
|
||||||
cusProductToPrices,
|
cusProductToPrices,
|
||||||
@@ -138,7 +139,10 @@ export const createAndFilterContUseItems = async ({
|
|||||||
const { start, end } = subToPeriodStartEnd({ sub });
|
const { start, end } = subToPeriodStartEnd({ sub });
|
||||||
await stripeCli.invoiceItems.create({
|
await stripeCli.invoiceItems.create({
|
||||||
customer: customer.processor?.id ?? undefined,
|
customer: customer.processor?.id ?? undefined,
|
||||||
amount: Math.round(item.amount * 100),
|
amount: atmnToStripeAmount({
|
||||||
|
amount: item.amount,
|
||||||
|
currency: org.default_currency || "usd",
|
||||||
|
}),
|
||||||
description: item.description,
|
description: item.description,
|
||||||
currency: org.default_currency || "usd",
|
currency: org.default_currency || "usd",
|
||||||
subscription: sub.id,
|
subscription: sub.id,
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import { isRedisMigrationCacheStale } from "@/external/redis/customerRedisRoutin
|
|||||||
import { runRedisOp } from "@/external/redis/utils/runRedisOp.js";
|
import { runRedisOp } from "@/external/redis/utils/runRedisOp.js";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
|
import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
|
||||||
|
import { lazyResetSubjectUsageWindows } from "@/internal/customers/actions/resetUsageWindows/lazyResetSubjectUsageWindows.js";
|
||||||
import { checkPendingMigrationsForCustomer } from "@/internal/migrations/v2/lazy/checkPendingMigrationsForCustomer.js";
|
import { checkPendingMigrationsForCustomer } from "@/internal/migrations/v2/lazy/checkPendingMigrationsForCustomer.js";
|
||||||
import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
|
import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
|
||||||
import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js";
|
import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js";
|
||||||
import { applyLiveAggregatedBalances } from "../balances/applyLiveAggregatedBalances.js";
|
import { applyLiveAggregatedBalances } from "../balances/applyLiveAggregatedBalances.js";
|
||||||
|
import { applyLiveUsageWindows } from "../balances/applyLiveUsageWindows.js";
|
||||||
import { getCachedFeatureBalancesBatch } from "../balances/getCachedFeatureBalances.js";
|
import { getCachedFeatureBalancesBatch } from "../balances/getCachedFeatureBalances.js";
|
||||||
import { buildFullSubjectKey } from "../builders/buildFullSubjectKey.js";
|
import { buildFullSubjectKey } from "../builders/buildFullSubjectKey.js";
|
||||||
import { buildFullSubjectViewEpochKey } from "../builders/buildFullSubjectViewEpochKey.js";
|
import { buildFullSubjectViewEpochKey } from "../builders/buildFullSubjectViewEpochKey.js";
|
||||||
@@ -195,12 +197,19 @@ export const getCachedFullSubject = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isCustomerSubject = !entityId;
|
const isCustomerSubject = !entityId;
|
||||||
|
// Capped features may have no entitlements, so they aren't guaranteed to be
|
||||||
|
// in meteredFeatures; union them in so their `_usage_windows` field is read.
|
||||||
|
const usageWindowFeatureIds = new Set(cached.usageWindowFeatureIds ?? []);
|
||||||
|
const batchFeatureIds = [
|
||||||
|
...new Set([...cached.meteredFeatures, ...usageWindowFeatureIds]),
|
||||||
|
];
|
||||||
const balancesOutcome = await getCachedFeatureBalancesBatch({
|
const balancesOutcome = await getCachedFeatureBalancesBatch({
|
||||||
ctx,
|
ctx,
|
||||||
customerId,
|
customerId,
|
||||||
featureIds: cached.meteredFeatures,
|
featureIds: batchFeatureIds,
|
||||||
customerEntitlementIdsByFeatureId: cached.customerEntitlementIdsByFeatureId,
|
customerEntitlementIdsByFeatureId: cached.customerEntitlementIdsByFeatureId,
|
||||||
includeAggregated: isCustomerSubject,
|
includeAggregated: isCustomerSubject,
|
||||||
|
usageWindowFeatureIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (balancesOutcome.kind === "missing") {
|
if (balancesOutcome.kind === "missing") {
|
||||||
@@ -220,9 +229,9 @@ export const getCachedFullSubject = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const balances = balancesOutcome.value;
|
const balances = balancesOutcome.value;
|
||||||
if (balances.length !== cached.meteredFeatures.length) {
|
if (balances.length !== batchFeatureIds.length) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[getCachedFullSubject] Incomplete cache for ${customerId}${entityId ? `:${entityId}` : ""}: expected ${cached.meteredFeatures.length} balance keys, got ${balances.length}. Rebuilding from DB, source: ${source}`,
|
`[getCachedFullSubject] Incomplete cache for ${customerId}${entityId ? `:${entityId}` : ""}: expected ${batchFeatureIds.length} balance keys, got ${balances.length}. Rebuilding from DB, source: ${source}`,
|
||||||
);
|
);
|
||||||
await invalidateCachedFullSubjectExact({
|
await invalidateCachedFullSubjectExact({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -249,8 +258,14 @@ export const getCachedFullSubject = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyLiveUsageWindows({
|
||||||
|
normalized,
|
||||||
|
featureBalances: balances,
|
||||||
|
});
|
||||||
|
|
||||||
const fullSubject = normalizedToFullSubject({ normalized });
|
const fullSubject = normalizedToFullSubject({ normalized });
|
||||||
await lazyResetSubjectEntitlements({ ctx, fullSubject });
|
await lazyResetSubjectEntitlements({ ctx, fullSubject });
|
||||||
|
await lazyResetSubjectUsageWindows({ ctx, fullSubject, normalized });
|
||||||
await checkPendingMigrationsForCustomer({
|
await checkPendingMigrationsForCustomer({
|
||||||
ctx,
|
ctx,
|
||||||
fullCustomer: fullSubjectToFullCustomer({ fullSubject }),
|
fullCustomer: fullSubjectToFullCustomer({ fullSubject }),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
CustomerNotFoundError,
|
CustomerNotFoundError,
|
||||||
EntityNotFoundError,
|
EntityNotFoundError,
|
||||||
type FullSubject,
|
type FullSubject,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
import { getFullSubjectNormalized } from "@/internal/customers/repos/getFullSubject/index.js";
|
import { getFullSubjectNormalized } from "@/internal/customers/repos/getFullSubject/index.js";
|
||||||
@@ -27,7 +27,6 @@ export const getOrSetCachedFullSubject = async ({
|
|||||||
|
|
||||||
let fetchedSubjectViewEpoch = 0;
|
let fetchedSubjectViewEpoch = 0;
|
||||||
|
|
||||||
|
|
||||||
if (useRedis) {
|
if (useRedis) {
|
||||||
// The pipeline inside getCachedFullSubject already fetches + refreshes
|
// The pipeline inside getCachedFullSubject already fetches + refreshes
|
||||||
// the epoch, so we reuse it on miss instead of a second round trip.
|
// the epoch, so we reuse it on miss instead of a second round trip.
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
|||||||
import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
|
import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
|
||||||
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
|
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
|
||||||
import { buildSharedFullSubjectBalanceKey } from "../../builders/buildSharedFullSubjectBalanceKey.js";
|
import { buildSharedFullSubjectBalanceKey } from "../../builders/buildSharedFullSubjectBalanceKey.js";
|
||||||
import { AGGREGATED_BALANCE_FIELD } from "../../config/fullSubjectCacheConfig.js";
|
import {
|
||||||
|
AGGREGATED_BALANCE_FIELD,
|
||||||
|
USAGE_WINDOWS_FIELD,
|
||||||
|
} from "../../config/fullSubjectCacheConfig.js";
|
||||||
import type { CachedFullSubject } from "../../fullSubjectCacheModel.js";
|
import type { CachedFullSubject } from "../../fullSubjectCacheModel.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,19 +64,37 @@ async function deleteFieldsFromManifest({
|
|||||||
const { customerEntitlementIdsByFeatureId } = manifest;
|
const { customerEntitlementIdsByFeatureId } = manifest;
|
||||||
if (!customerEntitlementIdsByFeatureId) return;
|
if (!customerEntitlementIdsByFeatureId) return;
|
||||||
|
|
||||||
|
// Capped features may have no entitlements, so their hashes only appear in
|
||||||
|
// usageWindowFeatureIds; union both so `_usage_windows` is cleared too.
|
||||||
|
// Safe to delete counters: capped tracks write through to PG synchronously,
|
||||||
|
// and the rebuild re-seeds the field from PG.
|
||||||
|
// Raw blob, no sanitize walker: cjson re-encodes empty arrays as {}, so
|
||||||
|
// array fields must be Array.isArray-guarded before spreading.
|
||||||
|
const usageWindowFeatureIds = Array.isArray(manifest.usageWindowFeatureIds)
|
||||||
|
? manifest.usageWindowFeatureIds
|
||||||
|
: [];
|
||||||
|
const featureIds = new Set([
|
||||||
|
...Object.keys(customerEntitlementIdsByFeatureId),
|
||||||
|
...usageWindowFeatureIds,
|
||||||
|
]);
|
||||||
|
|
||||||
const pipeline = redisV2.pipeline();
|
const pipeline = redisV2.pipeline();
|
||||||
let fieldCount = 0;
|
let fieldCount = 0;
|
||||||
|
|
||||||
for (const [featureId, cusEntIds] of Object.entries(
|
for (const featureId of featureIds) {
|
||||||
customerEntitlementIdsByFeatureId,
|
const rawCusEntIds = customerEntitlementIdsByFeatureId[featureId];
|
||||||
)) {
|
const cusEntIds = Array.isArray(rawCusEntIds) ? rawCusEntIds : [];
|
||||||
const balanceKey = buildSharedFullSubjectBalanceKey({
|
const balanceKey = buildSharedFullSubjectBalanceKey({
|
||||||
orgId: org.id,
|
orgId: org.id,
|
||||||
env,
|
env,
|
||||||
customerId,
|
customerId,
|
||||||
featureId,
|
featureId,
|
||||||
});
|
});
|
||||||
const fieldsToDelete = [...cusEntIds, AGGREGATED_BALANCE_FIELD];
|
const fieldsToDelete = [
|
||||||
|
...cusEntIds,
|
||||||
|
AGGREGATED_BALANCE_FIELD,
|
||||||
|
USAGE_WINDOWS_FIELD,
|
||||||
|
];
|
||||||
pipeline.hdel(balanceKey, ...fieldsToDelete);
|
pipeline.hdel(balanceKey, ...fieldsToDelete);
|
||||||
fieldCount += fieldsToDelete.length;
|
fieldCount += fieldsToDelete.length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { isRedisMigrationCacheStale } from "@/external/redis/customerRedisRoutin
|
|||||||
import { runRedisOp } from "@/external/redis/utils/runRedisOp.js";
|
import { runRedisOp } from "@/external/redis/utils/runRedisOp.js";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
|
import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
|
||||||
|
import { lazyResetSubjectUsageWindows } from "@/internal/customers/actions/resetUsageWindows/lazyResetSubjectUsageWindows.js";
|
||||||
import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
|
import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
|
||||||
import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js";
|
import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js";
|
||||||
import { applyLiveAggregatedBalances } from "../../balances/applyLiveAggregatedBalances.js";
|
import { applyLiveAggregatedBalances } from "../../balances/applyLiveAggregatedBalances.js";
|
||||||
|
import { applyLiveUsageWindows } from "../../balances/applyLiveUsageWindows.js";
|
||||||
import { getCachedFeatureBalancesBatch } from "../../balances/getCachedFeatureBalances.js";
|
import { getCachedFeatureBalancesBatch } from "../../balances/getCachedFeatureBalances.js";
|
||||||
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
|
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
|
||||||
import { buildFullSubjectViewEpochKey } from "../../builders/buildFullSubjectViewEpochKey.js";
|
import { buildFullSubjectViewEpochKey } from "../../builders/buildFullSubjectViewEpochKey.js";
|
||||||
@@ -212,9 +214,22 @@ export const getCachedPartialFullSubject = async ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const meteredFeatureIdsToFetch = featureIds.filter((featureId) =>
|
// Capped features carry the '_usage_windows' counter field and may have no
|
||||||
cached.meteredFeatures.includes(featureId),
|
// entitlements at all, so they must be part of the batch even when absent
|
||||||
|
// from meteredFeatures.
|
||||||
|
const usageWindowFeatureIds = new Set(
|
||||||
|
(cached.usageWindowFeatureIds ?? []).filter((featureId) =>
|
||||||
|
featureIds.includes(featureId),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
const meteredFeatureIdsToFetch = [
|
||||||
|
...new Set([
|
||||||
|
...featureIds.filter((featureId) =>
|
||||||
|
cached.meteredFeatures.includes(featureId),
|
||||||
|
),
|
||||||
|
...usageWindowFeatureIds,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
const isCustomerSubject = !entityId;
|
const isCustomerSubject = !entityId;
|
||||||
const featureBalancesOutcome = await getCachedFeatureBalancesBatch({
|
const featureBalancesOutcome = await getCachedFeatureBalancesBatch({
|
||||||
@@ -223,6 +238,7 @@ export const getCachedPartialFullSubject = async ({
|
|||||||
featureIds: meteredFeatureIdsToFetch,
|
featureIds: meteredFeatureIdsToFetch,
|
||||||
customerEntitlementIdsByFeatureId: cached.customerEntitlementIdsByFeatureId,
|
customerEntitlementIdsByFeatureId: cached.customerEntitlementIdsByFeatureId,
|
||||||
includeAggregated: isCustomerSubject,
|
includeAggregated: isCustomerSubject,
|
||||||
|
usageWindowFeatureIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
const invalidateIncomplete = () =>
|
const invalidateIncomplete = () =>
|
||||||
@@ -287,8 +303,14 @@ export const getCachedPartialFullSubject = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyLiveUsageWindows({
|
||||||
|
normalized,
|
||||||
|
featureBalances,
|
||||||
|
});
|
||||||
|
|
||||||
const fullSubject = normalizedToFullSubject({ normalized });
|
const fullSubject = normalizedToFullSubject({ normalized });
|
||||||
await lazyResetSubjectEntitlements({ ctx, fullSubject, normalized });
|
await lazyResetSubjectEntitlements({ ctx, fullSubject, normalized });
|
||||||
|
await lazyResetSubjectUsageWindows({ ctx, fullSubject, normalized });
|
||||||
return fullSubject;
|
return fullSubject;
|
||||||
},
|
},
|
||||||
invalidate: () =>
|
invalidate: () =>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NormalizedFullSubject } from "@autumn/shared";
|
|||||||
import { type FullSubject, normalizedToFullSubject } from "@autumn/shared";
|
import { type FullSubject, normalizedToFullSubject } from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
import { applyLiveAggregatedBalances } from "../balances/applyLiveAggregatedBalances.js";
|
import { applyLiveAggregatedBalances } from "../balances/applyLiveAggregatedBalances.js";
|
||||||
|
import { applyLiveUsageWindows } from "../balances/applyLiveUsageWindows.js";
|
||||||
import { getCachedFeatureBalancesBatch } from "../balances/getCachedFeatureBalances.js";
|
import { getCachedFeatureBalancesBatch } from "../balances/getCachedFeatureBalances.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +32,18 @@ export const rehydrateWithLiveBalances = async ({
|
|||||||
list.push(ce.id);
|
list.push(ce.id);
|
||||||
customerEntitlementIdsByFeatureId[ce.feature_id] = list;
|
customerEntitlementIdsByFeatureId[ce.feature_id] = list;
|
||||||
}
|
}
|
||||||
const featureIds = Object.keys(customerEntitlementIdsByFeatureId);
|
const usageWindowFeatureIds = new Set(
|
||||||
|
[
|
||||||
|
...(normalized.customer.usage_limits ?? []),
|
||||||
|
...(normalized.entity?.usage_limits ?? []),
|
||||||
|
].map((usageLimit) => usageLimit.feature_id),
|
||||||
|
);
|
||||||
|
const featureIds = [
|
||||||
|
...new Set([
|
||||||
|
...Object.keys(customerEntitlementIdsByFeatureId),
|
||||||
|
...usageWindowFeatureIds,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
const isCustomerSubject = !entityId;
|
const isCustomerSubject = !entityId;
|
||||||
const outcome = await getCachedFeatureBalancesBatch({
|
const outcome = await getCachedFeatureBalancesBatch({
|
||||||
@@ -40,6 +52,7 @@ export const rehydrateWithLiveBalances = async ({
|
|||||||
featureIds,
|
featureIds,
|
||||||
customerEntitlementIdsByFeatureId,
|
customerEntitlementIdsByFeatureId,
|
||||||
includeAggregated: isCustomerSubject,
|
includeAggregated: isCustomerSubject,
|
||||||
|
usageWindowFeatureIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (outcome.kind !== "ok") return undefined;
|
if (outcome.kind !== "ok") return undefined;
|
||||||
@@ -53,5 +66,10 @@ export const rehydrateWithLiveBalances = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyLiveUsageWindows({
|
||||||
|
normalized,
|
||||||
|
featureBalances: outcome.value,
|
||||||
|
});
|
||||||
|
|
||||||
return normalizedToFullSubject({ normalized });
|
return normalizedToFullSubject({ normalized });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export const setCachedFullSubject = async ({
|
|||||||
customerEntitlements: normalized.customer_entitlements,
|
customerEntitlements: normalized.customer_entitlements,
|
||||||
aggregatedCustomerEntitlements:
|
aggregatedCustomerEntitlements:
|
||||||
normalized.entity_aggregations?.aggregated_customer_entitlements ?? [],
|
normalized.entity_aggregations?.aggregated_customer_entitlements ?? [],
|
||||||
|
usageWindows: normalized.usage_windows ?? [],
|
||||||
|
usageWindowFeatureIds: cached.usageWindowFeatureIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
const keys: string[] = [subjectKey, epochKey];
|
const keys: string[] = [subjectKey, epochKey];
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import type {
|
import type {
|
||||||
AggregatedFeatureBalance,
|
AggregatedFeatureBalance,
|
||||||
NormalizedFullSubject,
|
NormalizedFullSubject,
|
||||||
|
UsageWindow,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { featureBalancesToHashFields } from "../../balances/featureBalancesToHashFields.js";
|
import { featureBalancesToHashFields } from "../../balances/featureBalancesToHashFields.js";
|
||||||
import { buildSharedFullSubjectBalanceKey } from "../../builders/buildSharedFullSubjectBalanceKey.js";
|
import { buildSharedFullSubjectBalanceKey } from "../../builders/buildSharedFullSubjectBalanceKey.js";
|
||||||
import { AGGREGATED_BALANCE_FIELD } from "../../config/fullSubjectCacheConfig.js";
|
import {
|
||||||
|
AGGREGATED_BALANCE_FIELD,
|
||||||
|
USAGE_WINDOWS_FIELD,
|
||||||
|
} from "../../config/fullSubjectCacheConfig.js";
|
||||||
|
|
||||||
export type SharedBalanceWrite = {
|
export type SharedBalanceWrite = {
|
||||||
balanceKey: string;
|
balanceKey: string;
|
||||||
@@ -17,12 +21,16 @@ export const buildSharedBalanceWrites = ({
|
|||||||
customerId,
|
customerId,
|
||||||
customerEntitlements,
|
customerEntitlements,
|
||||||
aggregatedCustomerEntitlements,
|
aggregatedCustomerEntitlements,
|
||||||
|
usageWindows = [],
|
||||||
|
usageWindowFeatureIds = [],
|
||||||
}: {
|
}: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
env: string;
|
env: string;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
customerEntitlements: NormalizedFullSubject["customer_entitlements"];
|
customerEntitlements: NormalizedFullSubject["customer_entitlements"];
|
||||||
aggregatedCustomerEntitlements: AggregatedFeatureBalance[];
|
aggregatedCustomerEntitlements: AggregatedFeatureBalance[];
|
||||||
|
usageWindows?: UsageWindow[];
|
||||||
|
usageWindowFeatureIds?: string[];
|
||||||
}): SharedBalanceWrite[] => {
|
}): SharedBalanceWrite[] => {
|
||||||
const balancesByFeatureId = new Map<string, typeof customerEntitlements>();
|
const balancesByFeatureId = new Map<string, typeof customerEntitlements>();
|
||||||
|
|
||||||
@@ -38,9 +46,24 @@ export const buildSharedBalanceWrites = ({
|
|||||||
aggregatedByFeatureId.set(aggregated.feature_id, aggregated);
|
aggregatedByFeatureId.set(aggregated.feature_id, aggregated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capped features get a `_usage_windows` field even with no rows and no
|
||||||
|
// entitlements: a present-but-empty field means "fresh counter", a missing
|
||||||
|
// field means "stale cache" and the deduction script fails closed on it.
|
||||||
|
const usageWindowsByFeatureId = new Map<string, UsageWindow[]>();
|
||||||
|
for (const featureId of usageWindowFeatureIds) {
|
||||||
|
usageWindowsByFeatureId.set(featureId, []);
|
||||||
|
}
|
||||||
|
for (const usageWindow of usageWindows) {
|
||||||
|
const existingWindows = usageWindowsByFeatureId.get(usageWindow.feature_id);
|
||||||
|
// Rows for features whose cap is no longer armed are not re-cached.
|
||||||
|
if (!existingWindows) continue;
|
||||||
|
existingWindows.push(usageWindow);
|
||||||
|
}
|
||||||
|
|
||||||
const allFeatureIds = new Set([
|
const allFeatureIds = new Set([
|
||||||
...balancesByFeatureId.keys(),
|
...balancesByFeatureId.keys(),
|
||||||
...aggregatedByFeatureId.keys(),
|
...aggregatedByFeatureId.keys(),
|
||||||
|
...usageWindowsByFeatureId.keys(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Array.from(allFeatureIds).map((featureId) => {
|
return Array.from(allFeatureIds).map((featureId) => {
|
||||||
@@ -52,6 +75,11 @@ export const buildSharedBalanceWrites = ({
|
|||||||
fields[AGGREGATED_BALANCE_FIELD] = JSON.stringify(aggregated);
|
fields[AGGREGATED_BALANCE_FIELD] = JSON.stringify(aggregated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const featureUsageWindows = usageWindowsByFeatureId.get(featureId);
|
||||||
|
if (featureUsageWindows) {
|
||||||
|
fields[USAGE_WINDOWS_FIELD] = JSON.stringify(featureUsageWindows);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
balanceKey: buildSharedFullSubjectBalanceKey({
|
balanceKey: buildSharedFullSubjectBalanceKey({
|
||||||
orgId,
|
orgId,
|
||||||
|
|||||||
20
server/src/internal/customers/cache/fullSubject/balances/applyLiveUsageWindows.ts
vendored
Normal file
20
server/src/internal/customers/cache/fullSubject/balances/applyLiveUsageWindows.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { NormalizedFullSubject } from "@autumn/shared";
|
||||||
|
import type { FeatureBalanceResult } from "./getCachedFeatureBalances.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill `normalized.usage_windows` from the live `_usage_windows` hash fields
|
||||||
|
* returned by the batch balance read. The cached subject view never carries
|
||||||
|
* counter rows (they'd be instantly stale), so this is the only hydration
|
||||||
|
* source on the cache-hit path.
|
||||||
|
*/
|
||||||
|
export const applyLiveUsageWindows = ({
|
||||||
|
normalized,
|
||||||
|
featureBalances,
|
||||||
|
}: {
|
||||||
|
normalized: NormalizedFullSubject;
|
||||||
|
featureBalances: FeatureBalanceResult[];
|
||||||
|
}): void => {
|
||||||
|
normalized.usage_windows = featureBalances.flatMap(
|
||||||
|
(featureBalance) => featureBalance.usageWindows ?? [],
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
import type { AggregatedFeatureBalance, SubjectBalance } from "@autumn/shared";
|
import type {
|
||||||
|
AggregatedFeatureBalance,
|
||||||
|
SubjectBalance,
|
||||||
|
UsageWindow,
|
||||||
|
} from "@autumn/shared";
|
||||||
import { runRedisOp } from "@/external/redis/utils/runRedisOp.js";
|
import { runRedisOp } from "@/external/redis/utils/runRedisOp.js";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
import { buildSharedFullSubjectBalanceKey } from "../builders/buildSharedFullSubjectBalanceKey.js";
|
import { buildSharedFullSubjectBalanceKey } from "../builders/buildSharedFullSubjectBalanceKey.js";
|
||||||
import { AGGREGATED_BALANCE_FIELD } from "../config/fullSubjectCacheConfig.js";
|
import {
|
||||||
|
AGGREGATED_BALANCE_FIELD,
|
||||||
|
USAGE_WINDOWS_FIELD,
|
||||||
|
} from "../config/fullSubjectCacheConfig.js";
|
||||||
import { roundSubjectBalance } from "../roundCacheBalance.js";
|
import { roundSubjectBalance } from "../roundCacheBalance.js";
|
||||||
import {
|
import {
|
||||||
sanitizeCachedAggregatedFeatureBalance,
|
sanitizeCachedAggregatedFeatureBalance,
|
||||||
@@ -13,6 +20,24 @@ export type FeatureBalanceResult = {
|
|||||||
featureId: string;
|
featureId: string;
|
||||||
balances: SubjectBalance[];
|
balances: SubjectBalance[];
|
||||||
aggregated?: AggregatedFeatureBalance;
|
aggregated?: AggregatedFeatureBalance;
|
||||||
|
/** Customer-scoped windowed-cap counters for this feature; only present for
|
||||||
|
* features in the requested usageWindowFeatureIds set. */
|
||||||
|
usageWindows?: UsageWindow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fail open: a missing/unparseable `_usage_windows` field reads as an empty
|
||||||
|
// counter set (the window restarts). cjson also encodes an empty Lua table as
|
||||||
|
// `{}`, so a non-array blob is an empty set, not corruption.
|
||||||
|
const parseUsageWindowsField = (
|
||||||
|
usageWindowsJson: string | null,
|
||||||
|
): UsageWindow[] => {
|
||||||
|
if (!usageWindowsJson) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(usageWindowsJson);
|
||||||
|
return Array.isArray(parsed) ? (parsed as UsageWindow[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FeatureBalanceOutcome =
|
export type FeatureBalanceOutcome =
|
||||||
@@ -118,12 +143,16 @@ export const getCachedFeatureBalancesBatch = async ({
|
|||||||
featureIds,
|
featureIds,
|
||||||
customerEntitlementIdsByFeatureId,
|
customerEntitlementIdsByFeatureId,
|
||||||
includeAggregated = false,
|
includeAggregated = false,
|
||||||
|
usageWindowFeatureIds,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
featureIds: string[];
|
featureIds: string[];
|
||||||
customerEntitlementIdsByFeatureId: Record<string, string[]>;
|
customerEntitlementIdsByFeatureId: Record<string, string[]>;
|
||||||
includeAggregated?: boolean;
|
includeAggregated?: boolean;
|
||||||
|
/** Features with an armed windowed cap: their `_usage_windows` field is
|
||||||
|
* read too. A missing field fails open (reads as an empty counter set). */
|
||||||
|
usageWindowFeatureIds?: Set<string>;
|
||||||
}): Promise<FeatureBalancesBatchOutcome> => {
|
}): Promise<FeatureBalancesBatchOutcome> => {
|
||||||
if (featureIds.length === 0) return { kind: "ok", value: [] };
|
if (featureIds.length === 0) return { kind: "ok", value: [] };
|
||||||
|
|
||||||
@@ -132,9 +161,11 @@ export const getCachedFeatureBalancesBatch = async ({
|
|||||||
for (const featureId of featureIds) {
|
for (const featureId of featureIds) {
|
||||||
const customerEntitlementIds =
|
const customerEntitlementIds =
|
||||||
customerEntitlementIdsByFeatureId[featureId] ?? [];
|
customerEntitlementIdsByFeatureId[featureId] ?? [];
|
||||||
const fields = includeAggregated
|
const fields = [...customerEntitlementIds];
|
||||||
? [...customerEntitlementIds, AGGREGATED_BALANCE_FIELD]
|
if (includeAggregated) fields.push(AGGREGATED_BALANCE_FIELD);
|
||||||
: customerEntitlementIds;
|
if (usageWindowFeatureIds?.has(featureId)) {
|
||||||
|
fields.push(USAGE_WINDOWS_FIELD);
|
||||||
|
}
|
||||||
pipeline.hmget(
|
pipeline.hmget(
|
||||||
buildSharedFullSubjectBalanceKey({
|
buildSharedFullSubjectBalanceKey({
|
||||||
orgId: org.id,
|
orgId: org.id,
|
||||||
@@ -167,7 +198,12 @@ export const getCachedFeatureBalancesBatch = async ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
let aggregated: AggregatedFeatureBalance | undefined;
|
let aggregated: AggregatedFeatureBalance | undefined;
|
||||||
let ceValues: (string | null)[];
|
let usageWindows: UsageWindow[] | undefined;
|
||||||
|
|
||||||
|
// Pop reserved fields in reverse push order: [_aggregated?, _usage_windows?].
|
||||||
|
if (usageWindowFeatureIds?.has(featureIds[i])) {
|
||||||
|
usageWindows = parseUsageWindowsField(allValues.pop() ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
if (includeAggregated) {
|
if (includeAggregated) {
|
||||||
const aggregatedJson = allValues.pop() ?? null;
|
const aggregatedJson = allValues.pop() ?? null;
|
||||||
@@ -181,11 +217,10 @@ export const getCachedFeatureBalancesBatch = async ({
|
|||||||
// Malformed _aggregated is non-fatal; fall back to subject string value
|
// Malformed _aggregated is non-fatal; fall back to subject string value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ceValues = allValues;
|
|
||||||
} else {
|
|
||||||
ceValues = allValues;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ceValues = allValues;
|
||||||
|
|
||||||
if (ceValues.length !== customerEntitlementIds.length)
|
if (ceValues.length !== customerEntitlementIds.length)
|
||||||
return {
|
return {
|
||||||
kind: "missing",
|
kind: "missing",
|
||||||
@@ -221,6 +256,7 @@ export const getCachedFeatureBalancesBatch = async ({
|
|||||||
featureId: featureIds[i],
|
featureId: featureIds[i],
|
||||||
balances,
|
balances,
|
||||||
aggregated,
|
aggregated,
|
||||||
|
usageWindows,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const buildDeductFromSubjectBalancesKeys = ({
|
|||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
customerEntitlementDeductions,
|
customerEntitlementDeductions,
|
||||||
fallbackFeatureId,
|
fallbackFeatureId,
|
||||||
|
usageWindowFeatureIds = [],
|
||||||
}: {
|
}: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
env: AppEnv;
|
env: AppEnv;
|
||||||
@@ -30,17 +31,26 @@ export const buildDeductFromSubjectBalancesKeys = ({
|
|||||||
idempotencyKey?: string | null;
|
idempotencyKey?: string | null;
|
||||||
customerEntitlementDeductions: { feature_id?: string }[];
|
customerEntitlementDeductions: { feature_id?: string }[];
|
||||||
fallbackFeatureId: string;
|
fallbackFeatureId: string;
|
||||||
|
// Capped features: their balance hashes carry the `_usage_windows` counter
|
||||||
|
// field, and a capped feature may have no entitlements (so no deduction
|
||||||
|
// entry references its hash). Declare those keys in KEYS[] too.
|
||||||
|
usageWindowFeatureIds?: string[];
|
||||||
}) => {
|
}) => {
|
||||||
const balanceKeysByFeatureId: Record<string, string> = {};
|
const balanceKeysByFeatureId: Record<string, string> = {};
|
||||||
for (const deductionEntry of customerEntitlementDeductions) {
|
const addFeatureKey = (featureId: string) => {
|
||||||
const targetFeatureId = deductionEntry.feature_id ?? fallbackFeatureId;
|
if (balanceKeysByFeatureId[featureId]) return;
|
||||||
if (balanceKeysByFeatureId[targetFeatureId]) continue;
|
balanceKeysByFeatureId[featureId] = buildSharedFullSubjectBalanceKey({
|
||||||
balanceKeysByFeatureId[targetFeatureId] = buildSharedFullSubjectBalanceKey({
|
|
||||||
orgId,
|
orgId,
|
||||||
env,
|
env,
|
||||||
customerId,
|
customerId,
|
||||||
featureId: targetFeatureId,
|
featureId,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
for (const deductionEntry of customerEntitlementDeductions) {
|
||||||
|
addFeatureKey(deductionEntry.feature_id ?? fallbackFeatureId);
|
||||||
|
}
|
||||||
|
for (const usageWindowFeatureId of usageWindowFeatureIds) {
|
||||||
|
addFeatureKey(usageWindowFeatureId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const balanceFeatureIds = Object.keys(balanceKeysByFeatureId);
|
const balanceFeatureIds = Object.keys(balanceKeysByFeatureId);
|
||||||
|
|||||||
@@ -3,3 +3,9 @@ import { seconds } from "@autumn/shared";
|
|||||||
export const FULL_SUBJECT_CACHE_TTL_SECONDS = seconds.days(3);
|
export const FULL_SUBJECT_CACHE_TTL_SECONDS = seconds.days(3);
|
||||||
export const FULL_SUBJECT_EPOCH_TTL_SECONDS = seconds.days(5);
|
export const FULL_SUBJECT_EPOCH_TTL_SECONDS = seconds.days(5);
|
||||||
export const AGGREGATED_BALANCE_FIELD = "_aggregated";
|
export const AGGREGATED_BALANCE_FIELD = "_aggregated";
|
||||||
|
// Customer-scoped usage-window counters for the capped feature, stored as a
|
||||||
|
// reserved field in that feature's balance hash (JSON array of rows). The
|
||||||
|
// rebuild writes it (even []) for armed caps; readers fail OPEN on a missing
|
||||||
|
// field (the window restarts), so it is a warm-read optimization, not a
|
||||||
|
// correctness contract.
|
||||||
|
export const USAGE_WINDOWS_FIELD = "_usage_windows";
|
||||||
|
|||||||
@@ -16,14 +16,23 @@ import {
|
|||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
// `usage_windows` is omitted alongside balances: counters live in the
|
||||||
|
// per-feature balance hashes (`_usage_windows` field) and would be instantly
|
||||||
|
// stale if serialized into the subject view.
|
||||||
export type CachedFullSubject = Omit<
|
export type CachedFullSubject = Omit<
|
||||||
NormalizedFullSubject,
|
NormalizedFullSubject,
|
||||||
"customer_entitlements"
|
"customer_entitlements" | "usage_windows"
|
||||||
> & {
|
> & {
|
||||||
_schemaVersion: number;
|
_schemaVersion: number;
|
||||||
_cachedAt: number;
|
_cachedAt: number;
|
||||||
meteredFeatures: string[];
|
meteredFeatures: string[];
|
||||||
customerEntitlementIdsByFeatureId: Record<string, string[]>;
|
customerEntitlementIdsByFeatureId: Record<string, string[]>;
|
||||||
|
/** Features with an armed windowed cap (customer + entity usage_limits);
|
||||||
|
* may include features with no entitlements, so it cannot be derived from
|
||||||
|
* customerEntitlementIdsByFeatureId. Drives `_usage_windows` reads,
|
||||||
|
* writes, and invalidation. Optional: cache entries written before usage
|
||||||
|
* windows existed don't carry it (treat as []). */
|
||||||
|
usageWindowFeatureIds?: string[];
|
||||||
subjectViewEpoch: number;
|
subjectViewEpoch: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,6 +81,9 @@ export const CachedFullSubjectSchema = z.object({
|
|||||||
_cachedAt: z.number(),
|
_cachedAt: z.number(),
|
||||||
meteredFeatures: z.array(z.string()),
|
meteredFeatures: z.array(z.string()),
|
||||||
customerEntitlementIdsByFeatureId: z.record(z.string(), z.array(z.string())),
|
customerEntitlementIdsByFeatureId: z.record(z.string(), z.array(z.string())),
|
||||||
|
// Optional (not defaulted): pre-usage-windows cache entries don't carry it,
|
||||||
|
// and the hole-filling walker must not invent it.
|
||||||
|
usageWindowFeatureIds: z.array(z.string()).optional(),
|
||||||
subjectViewEpoch: z.number(),
|
subjectViewEpoch: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,6 +117,15 @@ export const normalizedToCachedFullSubject = ({
|
|||||||
|
|
||||||
const meteredFeatures = [...meteredFeatureSet];
|
const meteredFeatures = [...meteredFeatureSet];
|
||||||
|
|
||||||
|
const usageWindowFeatureIds = [
|
||||||
|
...new Set(
|
||||||
|
[
|
||||||
|
...(normalized.customer.usage_limits ?? []),
|
||||||
|
...(normalized.entity?.usage_limits ?? []),
|
||||||
|
].map((usageLimit) => usageLimit.feature_id),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subjectType: normalized.subjectType,
|
subjectType: normalized.subjectType,
|
||||||
customerId: normalized.customerId,
|
customerId: normalized.customerId,
|
||||||
@@ -128,6 +149,7 @@ export const normalizedToCachedFullSubject = ({
|
|||||||
_cachedAt: Date.now(),
|
_cachedAt: Date.now(),
|
||||||
meteredFeatures,
|
meteredFeatures,
|
||||||
customerEntitlementIdsByFeatureId,
|
customerEntitlementIdsByFeatureId,
|
||||||
|
usageWindowFeatureIds,
|
||||||
subjectViewEpoch,
|
subjectViewEpoch,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -159,5 +181,8 @@ export const cachedFullSubjectToNormalized = ({
|
|||||||
invoices: cached.invoices,
|
invoices: cached.invoices,
|
||||||
entity_aggregations: cached.entity_aggregations,
|
entity_aggregations: cached.entity_aggregations,
|
||||||
migration_item_runs: cached.migration_item_runs ?? [],
|
migration_item_runs: cached.migration_item_runs ?? [],
|
||||||
|
// Live data: filled from the balance hashes' `_usage_windows` fields by
|
||||||
|
// the caller, never from the cached subject view.
|
||||||
|
usage_windows: [],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import { Decimal } from "decimal.js";
|
|||||||
* Round a number to avoid floating-point precision issues from Lua 5.1 double arithmetic.
|
* Round a number to avoid floating-point precision issues from Lua 5.1 double arithmetic.
|
||||||
* Uses Decimal.js toDecimalPlaces(10) — enough precision while eliminating float drift.
|
* Uses Decimal.js toDecimalPlaces(10) — enough precision while eliminating float drift.
|
||||||
*/
|
*/
|
||||||
export const roundCacheBalance = (
|
export const roundCacheBalance = (value: number | null | undefined): number => {
|
||||||
value: number | null | undefined,
|
|
||||||
): number => {
|
|
||||||
if (value === null || value === undefined) return 0;
|
if (value === null || value === undefined) return 0;
|
||||||
return new Decimal(value).toDecimalPlaces(10).toNumber();
|
return new Decimal(value).toDecimalPlaces(10).toNumber();
|
||||||
};
|
};
|
||||||
@@ -23,11 +21,19 @@ export const roundSubjectBalance = ({
|
|||||||
}): SubjectBalance => {
|
}): SubjectBalance => {
|
||||||
subjectBalance.balance = roundCacheBalance(subjectBalance.balance);
|
subjectBalance.balance = roundCacheBalance(subjectBalance.balance);
|
||||||
|
|
||||||
if (subjectBalance.adjustment !== null && subjectBalance.adjustment !== undefined)
|
if (
|
||||||
|
subjectBalance.adjustment !== null &&
|
||||||
|
subjectBalance.adjustment !== undefined
|
||||||
|
)
|
||||||
subjectBalance.adjustment = roundCacheBalance(subjectBalance.adjustment);
|
subjectBalance.adjustment = roundCacheBalance(subjectBalance.adjustment);
|
||||||
|
|
||||||
if (subjectBalance.additional_balance !== null && subjectBalance.additional_balance !== undefined)
|
if (
|
||||||
subjectBalance.additional_balance = roundCacheBalance(subjectBalance.additional_balance);
|
subjectBalance.additional_balance !== null &&
|
||||||
|
subjectBalance.additional_balance !== undefined
|
||||||
|
)
|
||||||
|
subjectBalance.additional_balance = roundCacheBalance(
|
||||||
|
subjectBalance.additional_balance,
|
||||||
|
);
|
||||||
|
|
||||||
if (subjectBalance.entities && typeof subjectBalance.entities === "object") {
|
if (subjectBalance.entities && typeof subjectBalance.entities === "object") {
|
||||||
for (const entityId of Object.keys(subjectBalance.entities)) {
|
for (const entityId of Object.keys(subjectBalance.entities)) {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
CustomerExpand,
|
CustomerExpand,
|
||||||
type CustomerLegacyData,
|
type CustomerLegacyData,
|
||||||
type FullCustomer,
|
type FullCustomer,
|
||||||
|
fullCustomerToFullSubject,
|
||||||
|
fullSubjectToApiUsageLimits,
|
||||||
|
orgToInStatuses,
|
||||||
scopeExpandForCtx,
|
scopeExpandForCtx,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
@@ -45,6 +48,11 @@ export const getApiCustomerBase = async ({
|
|||||||
ctx: subscriptionsScopedCtx,
|
ctx: subscriptionsScopedCtx,
|
||||||
fullCus,
|
fullCus,
|
||||||
});
|
});
|
||||||
|
const usageLimits = fullSubjectToApiUsageLimits({
|
||||||
|
fullSubject: fullCustomerToFullSubject({ fullCustomer: fullCus }),
|
||||||
|
features: ctx.features,
|
||||||
|
inStatuses: orgToInStatuses({ org: ctx.org }),
|
||||||
|
});
|
||||||
|
|
||||||
const apiCustomer = ApiCustomerV5Schema.extend({
|
const apiCustomer = ApiCustomerV5Schema.extend({
|
||||||
autumn_id: z.string().optional(),
|
autumn_id: z.string().optional(),
|
||||||
@@ -69,6 +77,7 @@ export const getApiCustomerBase = async ({
|
|||||||
billing_controls: {
|
billing_controls: {
|
||||||
auto_topups: fullCus.auto_topups ?? undefined,
|
auto_topups: fullCus.auto_topups ?? undefined,
|
||||||
spend_limits: fullCus.spend_limits ?? undefined,
|
spend_limits: fullCus.spend_limits ?? undefined,
|
||||||
|
usage_limits: usageLimits,
|
||||||
usage_alerts: fullCus.usage_alerts ?? undefined,
|
usage_alerts: fullCus.usage_alerts ?? undefined,
|
||||||
overage_allowed: fullCus.overage_allowed ?? undefined,
|
overage_allowed: fullCus.overage_allowed ?? undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user