Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions apps/api/src/routes/organization.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";

import { app } from "@/index.js";
import { createTestUser, deleteAll } from "@/testing.js";

import { db, tables } from "@llmgateway/db";

describe("organization route", () => {
let token: string;

beforeEach(async () => {
token = await createTestUser();

await db.insert(tables.organization).values({
id: "test-org-id",
name: "Test Organization",
billingEmail: "test@example.com",
autoTopUpEnabled: false,
autoTopUpThreshold: "10",
autoTopUpAmount: "10",
});

await db.insert(tables.userOrganization).values({
userId: "test-user-id",
organizationId: "test-org-id",
role: "owner",
});
});

afterEach(async () => {
await deleteAll();
});

test("PATCH /orgs/{id} logs enabling auto top-up in audit log", async () => {
const response = await app.request("/orgs/test-org-id", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Cookie: token,
},
body: JSON.stringify({
autoTopUpEnabled: true,
}),
});

expect(response.status).toBe(200);

const auditLogs = await db.query.auditLog.findMany({
where: {
organizationId: {
eq: "test-org-id",
},
action: {
eq: "payment.auto_topup.update",
},
},
});

expect(auditLogs).toHaveLength(1);
expect(auditLogs[0]?.userId).toBe("test-user-id");
expect(auditLogs[0]?.resourceId).toBe("test-org-id");
expect(auditLogs[0]?.metadata).toMatchObject({
changes: {
autoTopUpEnabled: {
old: false,
new: true,
},
},
});
});

test("PATCH /orgs/{id} logs top-up setting changes separately from organization updates", async () => {
const response = await app.request("/orgs/test-org-id", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Cookie: token,
},
body: JSON.stringify({
name: "Renamed Organization",
autoTopUpThreshold: 25,
autoTopUpAmount: 50,
}),
});

expect(response.status).toBe(200);

const orgAuditLogs = await db.query.auditLog.findMany({
where: {
organizationId: {
eq: "test-org-id",
},
action: {
eq: "organization.update",
},
},
});
expect(orgAuditLogs).toHaveLength(1);
expect(orgAuditLogs[0]?.metadata).toMatchObject({
changes: {
name: {
old: "Test Organization",
new: "Renamed Organization",
},
},
});
Comment on lines +99 to +106

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Assert exact organization.update change keys to enforce separation.

Line 99 currently uses toMatchObject, which permits extra fields. A regression where auto-top-up fields leak into organization.update would still pass.

🔧 Tighten the assertion
-		expect(orgAuditLogs[0]?.metadata).toMatchObject({
-			changes: {
-				name: {
-					old: "Test Organization",
-					new: "Renamed Organization",
-				},
-			},
-		});
+		const orgChanges = (orgAuditLogs[0]?.metadata as {
+			changes: Record<string, unknown>;
+		}).changes;
+		expect(Object.keys(orgChanges)).toEqual(["name"]);
+		expect(orgChanges).toMatchObject({
+			name: {
+				old: "Test Organization",
+				new: "Renamed Organization",
+			},
+		});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/organization.spec.ts` around lines 99 - 106, The test
currently uses toMatchObject which allows extra fields; change the assertion on
orgAuditLogs[0]?.metadata to a strict equality check so no extra keys (e.g.,
auto-top-up) can sneak into organization.update. Replace the toMatchObject call
with an exact deep equality (e.g., expect(orgAuditLogs[0]?.metadata).toEqual({
changes: { name: { old: "Test Organization", new: "Renamed Organization" } } }))
or equivalent strict check, ensuring you reference the metadata -> changes ->
name structure exactly.


const autoTopUpAuditLogs = await db.query.auditLog.findMany({
where: {
organizationId: {
eq: "test-org-id",
},
action: {
eq: "payment.auto_topup.update",
},
},
});
expect(autoTopUpAuditLogs).toHaveLength(1);
expect(autoTopUpAuditLogs[0]?.metadata).toMatchObject({
changes: {
autoTopUpThreshold: {
old: "10",
new: "25",
},
autoTopUpAmount: {
old: "10",
new: "50",
},
},
});
});
});
52 changes: 45 additions & 7 deletions apps/api/src/routes/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ const updateOrganizationSchema = z.object({
autoTopUpAmount: z.number().min(10).optional(),
});

const AUTO_TOP_UP_AUDIT_FIELDS = [
"autoTopUpEnabled",
"autoTopUpThreshold",
"autoTopUpAmount",
] as const;

const transactionSchema = z.object({
id: z.string(),
createdAt: z.date(),
Expand Down Expand Up @@ -433,6 +439,11 @@ organization.openapi(updateOrganization, async (c) => {
}
if (autoTopUpEnabled !== undefined) {
updateData.autoTopUpEnabled = autoTopUpEnabled;
if (autoTopUpEnabled && !userOrganization.organization?.autoTopUpEnabled) {
updateData.paymentFailureCount = 0;
updateData.lastPaymentFailureAt = null;
updateData.paymentFailureStartedAt = null;
}
}
if (autoTopUpThreshold !== undefined) {
updateData.autoTopUpThreshold = autoTopUpThreshold.toString();
Expand All @@ -449,6 +460,7 @@ organization.openapi(updateOrganization, async (c) => {

// Build changes metadata for audit log
const changes: Record<string, { old: unknown; new: unknown }> = {};
const autoTopUpChanges: Record<string, { old: unknown; new: unknown }> = {};
const oldOrg = userOrganization.organization!;
if (name !== undefined && name !== oldOrg.name) {
changes.name = { old: oldOrg.name, new: name };
Expand Down Expand Up @@ -493,32 +505,58 @@ organization.openapi(updateOrganization, async (c) => {
autoTopUpEnabled !== undefined &&
autoTopUpEnabled !== oldOrg.autoTopUpEnabled
) {
changes.autoTopUpEnabled = {
autoTopUpChanges.autoTopUpEnabled = {
old: oldOrg.autoTopUpEnabled,
new: autoTopUpEnabled,
};
}
if (autoTopUpThreshold !== undefined) {
changes.autoTopUpThreshold = {
if (
autoTopUpThreshold !== undefined &&
autoTopUpThreshold.toString() !== oldOrg.autoTopUpThreshold
) {
autoTopUpChanges.autoTopUpThreshold = {
old: oldOrg.autoTopUpThreshold,
new: autoTopUpThreshold.toString(),
};
}
if (autoTopUpAmount !== undefined) {
changes.autoTopUpAmount = {
if (
autoTopUpAmount !== undefined &&
autoTopUpAmount.toString() !== oldOrg.autoTopUpAmount
) {
autoTopUpChanges.autoTopUpAmount = {
old: oldOrg.autoTopUpAmount,
new: autoTopUpAmount.toString(),
};
}

if (Object.keys(changes).length > 0) {
const organizationChanges = Object.fromEntries(
Object.entries(changes).filter(
([field]) =>
!AUTO_TOP_UP_AUDIT_FIELDS.includes(
field as (typeof AUTO_TOP_UP_AUDIT_FIELDS)[number],
),
),
);

if (Object.keys(organizationChanges).length > 0) {
await logAuditEvent({
organizationId: id,
userId: user.id,
action: "organization.update",
resourceType: "organization",
resourceId: id,
metadata: { changes },
metadata: { changes: organizationChanges },
});
}

if (Object.keys(autoTopUpChanges).length > 0) {
await logAuditEvent({
organizationId: id,
userId: user.id,
action: "payment.auto_topup.update",
resourceType: "organization",
resourceId: id,
metadata: { changes: autoTopUpChanges },
});
}

Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ async function recordCreditTopUp({
credits: sql`${tables.organization.credits} + ${finalCreditAmount}`,
paymentFailureCount: 0,
lastPaymentFailureAt: null,
paymentFailureStartedAt: null,
})
.where(eq(tables.organization.id, organizationId));

Expand Down Expand Up @@ -810,6 +811,7 @@ async function handlePaymentIntentSucceeded(
credits: sql`${tables.organization.credits} + ${finalCreditAmount}`,
paymentFailureCount: 0,
lastPaymentFailureAt: null,
paymentFailureStartedAt: null,
})
.where(eq(tables.organization.id, organizationId));

Expand Down Expand Up @@ -1027,6 +1029,7 @@ async function handlePaymentIntentFailed(
// Calculate new failure count and check if we should send an email
const previousFailureCount = organization.paymentFailureCount ?? 0;
const previousFailureAt = organization.lastPaymentFailureAt;
const failureStartedAt = organization.paymentFailureStartedAt ?? new Date();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope failure streak to auto top-up intents

paymentFailureStartedAt is initialized for every payment_intent.payment_failed event, including non-auto-top-up failures (e.g., manual top-ups or subscription-related payment intents resolved via customer lookup). With this commit’s worker logic (apps/worker/src/worker.ts) disabling auto top-up after 7 days based on this field, unrelated payment failures can now shut off auto top-up for an organization even when auto top-up itself hasn’t been failing. Restrict setting this streak start (and the associated counters used for disablement) to verified auto top-up failures only (such as events carrying the auto top-up transaction metadata).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict failure streak updates to auto top-up intents

This assignment updates the 7-day disable timer for every payment_intent.payment_failed event that resolves an organization, not just failed auto top-ups. Fresh evidence in this commit: the transactionId-missing path explicitly handles manual/non-auto payments ("for manual top-ups or payments without transactionId") and still flows into this unconditional paymentFailureStartedAt update, while processAutoTopUp now disables auto top-up once that timestamp is 7 days old. A failed non-auto payment can therefore disable auto top-up even when auto top-up itself was healthy.

Useful? React with 👍 / 👎.

const newFailureCount = previousFailureCount + 1;

// Update organization with new failure count and timestamp
Expand All @@ -1035,6 +1038,7 @@ async function handlePaymentIntentFailed(
.set({
paymentFailureCount: newFailureCount,
lastPaymentFailureAt: new Date(),
paymentFailureStartedAt: failureStartedAt,
})
Comment on lines 1032 to 1042

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

failureStartedAt currently falls back to new Date() when paymentFailureStartedAt is null. For existing rows (new column) that already have paymentFailureCount > 0/lastPaymentFailureAt populated, this effectively “restarts” the streak on the next failure and can delay auto top-up disabling. Consider falling back to organization.lastPaymentFailureAt (if present) before new Date() so the streak start is preserved as best as possible for pre-existing data.

Copilot uses AI. Check for mistakes.
.where(eq(tables.organization.id, organizationId));

Expand Down
1 change: 1 addition & 0 deletions apps/api/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export async function deleteAll() {

await Promise.all([
db.delete(tables.log),
db.delete(tables.auditLog),
db.delete(tables.apiKey),
db.delete(tables.providerKey),
db.delete(projectHourlyStats),
Expand Down
2 changes: 1 addition & 1 deletion apps/code/src/lib/api/v1.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6753,7 +6753,7 @@ export interface paths {
organizationId: string;
userId: string;
/** @enum {string} */
action: "organization.create" | "organization.update" | "organization.delete" | "project.create" | "project.update" | "project.delete" | "team_member.add" | "team_member.update" | "team_member.remove" | "api_key.create" | "api_key.update_status" | "api_key.update_limit" | "api_key.delete" | "api_key.iam_rule.create" | "api_key.iam_rule.update" | "api_key.iam_rule.delete" | "provider_key.create" | "provider_key.update" | "provider_key.delete" | "subscription.create" | "subscription.cancel" | "subscription.resume" | "subscription.upgrade_yearly" | "payment.method.set_default" | "payment.method.delete" | "payment.credit_topup" | "credits.gift" | "dev_plan.subscribe" | "dev_plan.cancel" | "dev_plan.resume" | "dev_plan.change_tier" | "dev_plan.update_settings";
action: "organization.create" | "organization.update" | "organization.delete" | "project.create" | "project.update" | "project.delete" | "team_member.add" | "team_member.update" | "team_member.remove" | "api_key.create" | "api_key.update_status" | "api_key.update_limit" | "api_key.delete" | "api_key.iam_rule.create" | "api_key.iam_rule.update" | "api_key.iam_rule.delete" | "provider_key.create" | "provider_key.update" | "provider_key.delete" | "subscription.create" | "subscription.cancel" | "subscription.resume" | "subscription.upgrade_yearly" | "payment.method.set_default" | "payment.method.delete" | "payment.credit_topup" | "payment.auto_topup.update" | "payment.auto_topup.disable" | "credits.gift" | "dev_plan.subscribe" | "dev_plan.cancel" | "dev_plan.resume" | "dev_plan.change_tier" | "dev_plan.update_settings";
/** @enum {string} */
resourceType: "organization" | "project" | "team_member" | "api_key" | "iam_rule" | "provider_key" | "subscription" | "payment_method" | "payment" | "dev_plan";
resourceId: string | null;
Expand Down
6 changes: 6 additions & 0 deletions apps/gateway/src/lib/rate-limit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ describe("Rate Limiting", () => {
referralEarnings: "0",
paymentFailureCount: 0,
lastPaymentFailureAt: null,
paymentFailureStartedAt: null,
isPersonal: false,
devPlan: "none" as const,
devPlanCreditsUsed: "0",
Expand Down Expand Up @@ -156,6 +157,7 @@ describe("Rate Limiting", () => {
referralEarnings: "0",
paymentFailureCount: 0,
lastPaymentFailureAt: null,
paymentFailureStartedAt: null,
isPersonal: false,
devPlan: "none" as const,
devPlanCreditsUsed: "0",
Expand Down Expand Up @@ -214,6 +216,7 @@ describe("Rate Limiting", () => {
referralEarnings: "0",
paymentFailureCount: 0,
lastPaymentFailureAt: null,
paymentFailureStartedAt: null,
isPersonal: false,
devPlan: "none" as const,
devPlanCreditsUsed: "0",
Expand Down Expand Up @@ -266,6 +269,7 @@ describe("Rate Limiting", () => {
referralEarnings: "0",
paymentFailureCount: 0,
lastPaymentFailureAt: null,
paymentFailureStartedAt: null,
isPersonal: false,
devPlan: "none" as const,
devPlanCreditsUsed: "0",
Expand Down Expand Up @@ -326,6 +330,7 @@ describe("Rate Limiting", () => {
referralEarnings: "0",
paymentFailureCount: 0,
lastPaymentFailureAt: null,
paymentFailureStartedAt: null,
isPersonal: false,
devPlan: "none" as const,
devPlanCreditsUsed: "0",
Expand Down Expand Up @@ -386,6 +391,7 @@ describe("Rate Limiting", () => {
referralEarnings: "0",
paymentFailureCount: 0,
lastPaymentFailureAt: null,
paymentFailureStartedAt: null,
isPersonal: false,
devPlan: "none" as const,
devPlanCreditsUsed: "0",
Expand Down
2 changes: 1 addition & 1 deletion apps/playground/src/lib/api/v1.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6753,7 +6753,7 @@ export interface paths {
organizationId: string;
userId: string;
/** @enum {string} */
action: "organization.create" | "organization.update" | "organization.delete" | "project.create" | "project.update" | "project.delete" | "team_member.add" | "team_member.update" | "team_member.remove" | "api_key.create" | "api_key.update_status" | "api_key.update_limit" | "api_key.delete" | "api_key.iam_rule.create" | "api_key.iam_rule.update" | "api_key.iam_rule.delete" | "provider_key.create" | "provider_key.update" | "provider_key.delete" | "subscription.create" | "subscription.cancel" | "subscription.resume" | "subscription.upgrade_yearly" | "payment.method.set_default" | "payment.method.delete" | "payment.credit_topup" | "credits.gift" | "dev_plan.subscribe" | "dev_plan.cancel" | "dev_plan.resume" | "dev_plan.change_tier" | "dev_plan.update_settings";
action: "organization.create" | "organization.update" | "organization.delete" | "project.create" | "project.update" | "project.delete" | "team_member.add" | "team_member.update" | "team_member.remove" | "api_key.create" | "api_key.update_status" | "api_key.update_limit" | "api_key.delete" | "api_key.iam_rule.create" | "api_key.iam_rule.update" | "api_key.iam_rule.delete" | "provider_key.create" | "provider_key.update" | "provider_key.delete" | "subscription.create" | "subscription.cancel" | "subscription.resume" | "subscription.upgrade_yearly" | "payment.method.set_default" | "payment.method.delete" | "payment.credit_topup" | "payment.auto_topup.update" | "payment.auto_topup.disable" | "credits.gift" | "dev_plan.subscribe" | "dev_plan.cancel" | "dev_plan.resume" | "dev_plan.change_tier" | "dev_plan.update_settings";
/** @enum {string} */
resourceType: "organization" | "project" | "team_member" | "api_key" | "iam_rule" | "provider_key" | "subscription" | "payment_method" | "payment" | "dev_plan";
resourceId: string | null;
Expand Down
2 changes: 1 addition & 1 deletion apps/ui/src/lib/api/v1.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6753,7 +6753,7 @@ export interface paths {
organizationId: string;
userId: string;
/** @enum {string} */
action: "organization.create" | "organization.update" | "organization.delete" | "project.create" | "project.update" | "project.delete" | "team_member.add" | "team_member.update" | "team_member.remove" | "api_key.create" | "api_key.update_status" | "api_key.update_limit" | "api_key.delete" | "api_key.iam_rule.create" | "api_key.iam_rule.update" | "api_key.iam_rule.delete" | "provider_key.create" | "provider_key.update" | "provider_key.delete" | "subscription.create" | "subscription.cancel" | "subscription.resume" | "subscription.upgrade_yearly" | "payment.method.set_default" | "payment.method.delete" | "payment.credit_topup" | "credits.gift" | "dev_plan.subscribe" | "dev_plan.cancel" | "dev_plan.resume" | "dev_plan.change_tier" | "dev_plan.update_settings";
action: "organization.create" | "organization.update" | "organization.delete" | "project.create" | "project.update" | "project.delete" | "team_member.add" | "team_member.update" | "team_member.remove" | "api_key.create" | "api_key.update_status" | "api_key.update_limit" | "api_key.delete" | "api_key.iam_rule.create" | "api_key.iam_rule.update" | "api_key.iam_rule.delete" | "provider_key.create" | "provider_key.update" | "provider_key.delete" | "subscription.create" | "subscription.cancel" | "subscription.resume" | "subscription.upgrade_yearly" | "payment.method.set_default" | "payment.method.delete" | "payment.credit_topup" | "payment.auto_topup.update" | "payment.auto_topup.disable" | "credits.gift" | "dev_plan.subscribe" | "dev_plan.cancel" | "dev_plan.resume" | "dev_plan.change_tier" | "dev_plan.update_settings";
/** @enum {string} */
resourceType: "organization" | "project" | "team_member" | "api_key" | "iam_rule" | "provider_key" | "subscription" | "payment_method" | "payment" | "dev_plan";
resourceId: string | null;
Expand Down
Loading
Loading