Skip to content

balances schema#879

Merged
johnyeocx merged 1 commit intodevfrom
balances-schema
Mar 4, 2026
Merged

balances schema#879
johnyeocx merged 1 commit intodevfrom
balances-schema

Conversation

@johnyeocx
Copy link
Collaborator

@johnyeocx johnyeocx commented Mar 4, 2026

Summary by cubic

Adds a new balances.delete endpoint and introduces balance_id on balances so you can target, update, and delete specific entries. Also switches APIs to included_grant, adds next_reset_at updates, and improves cache/DB sync.

  • New Features

    • New API route: POST /v1/balances.delete with OpenAPI contract, server handler, and CLI client.
    • balance_id on create; validated unique per customer; returned as breakdown.id; shown in BalanceEditSheet.
    • Update endpoints accept balance_id or interval to scope changes; support included_grant and next_reset_at (lifetime balances are rejected).
    • Customer entitlements gain external_id; filters support balance_id; DB + Redis updated via a new updateDbAndCache action.
    • Track deductions can trigger auto top-up; enabled for track flows.
  • Migration

    • Use included_grant instead of included/granted_balance in public create/update requests.
    • Set balance_id when creating a balance to target it later; breakdown.id returns external_id if present.
    • To delete, call balances.delete with customer_id and optionally feature_id, interval, or balance_id; paid balances cannot be deleted.
    • Updates require feature_id; use balance_id or interval to scope, and next_reset_at to adjust the reset date.

Written for commit c4e6ac2. Summary will update on new commits.

Greptile Summary

This PR introduces a balance_id / external_id scheme for customer entitlements, enabling callers to tag balances with stable user-defined IDs at create time and then target them precisely in subsequent update and delete calls. It also adds the new balances.delete RPC endpoint and refactors the update flow to fetch the full customer once upfront rather than once per sub-operation.

Key changes:

  • [API changes] New balance_id field on create/update/deleteexternal_id is stored on customer_entitlements and exposed as balance_id in the public API; fullCustomerToCustomerEntitlements filters by external_id ?? id so both explicit and fallback (internal-id) addressing work.
  • [API changes] New balances.delete endpointDELETE /v1/balances.delete with DeleteBalanceParamsV0Schema; blocks deletion of paid (price-attached) entitlements and invalidates the full-customer cache on success.
  • [API changes] included_grant replaces included / granted_balance — create and update params are renamed for consistency.
  • [Improvements] updateNextResetAt extracted as a standalone helper — no longer requires customer_entitlement_id; targets by sorting on next_reset_at ascending with optional balance_id / interval filters.
  • [Improvements] updateCusEntDbAndCache action — new action that atomically writes a cusEnt update to Postgres and the Redis FullCustomer cache, replacing the pattern of calling CusEntService.update and then purging the full API cache.
  • [Improvements] Single full-customer fetch in handleUpdateBalance — the customer is fetched once at the top of the handler and threaded through runUpdateBalanceV2, runUpdateUsage, updateGrantedBalance, and updateNextResetAt.
  • [Bug fixes] CusEntService.insert default balance — removes the as any cast and defaults balance to 0 to avoid inserting nulls.
  • [Improvements] New index on customer_entitlements.entitlement_id — improves lookup performance for entitlement-based queries.

Confidence Score: 1/5

  • Critical safety issue: DeleteBalanceParamsV0Schema lacks a refine guard, allowing requests with only customer_id to delete all non-paid entitlements.
  • The missing .refine() guard in the delete params schema is a critical bug. A caller passing only customer_id will silently match and delete all non-paid entitlements for that customer in one call, when the intent is clearly to target a specific balance. This is a data loss issue that must be fixed before merge.
  • shared/api/balances/delete/deleteBalanceParams.ts — Add .refine() to enforce that either feature_id or balance_id is provided.

Last reviewed commit: c4e6ac2

@johnyeocx johnyeocx requested a review from ay-rod as a code owner March 4, 2026 16:54
@vercel
Copy link

vercel bot commented Mar 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
autumn-vite Ready Ready Preview, Comment Mar 4, 2026 4:55pm

Request Review

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

5 issues found across 58 files

Confidence score: 3/5

  • There is meaningful regression risk in server/src/internal/balances/updateBalance/updateNextResetAt.ts: allowing an empty customerId when fullCustomer.id is missing could direct updates to an invalid customer record.
  • server/src/internal/balances/updateBalance/updateGrantedBalance.ts appears user-impacting if featureId is omitted, since updates may fall through to the first entitlement and modify the wrong balance; this is the highest-impact behavior risk in the set.
  • Test coverage signals are mixed: server/tests/integration/balances/update/balance/update-balance-with-id.test.ts does not actually exercise the documented fallback path, and server/tests/integration/balances/create/create-balance-with-id.test.ts can pass on non-conflict errors, so these may miss real regressions.
  • Pay close attention to server/src/internal/balances/updateBalance/updateNextResetAt.ts, server/src/internal/balances/updateBalance/updateGrantedBalance.ts, server/tests/integration/balances/update/balance/update-balance-with-id.test.ts, and server/tests/integration/balances/create/create-balance-with-id.test.ts - potential mis-targeted balance updates and insufficient assertions around fallback/conflict behavior.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="server/src/internal/balances/updateBalance/updateNextResetAt.ts">

<violation number="1" location="server/src/internal/balances/updateBalance/updateNextResetAt.ts:55">
P2: Avoid passing an empty string for customerId. If `fullCustomer.id` can be undefined, use a valid fallback (e.g., internal_id) or throw; otherwise updates may target an invalid customer record.</violation>
</file>

<file name="server/src/internal/customers/cusProducts/cusEnts/actions/updateCusEntDbAndCache.ts">

<violation number="1" location="server/src/internal/customers/cusProducts/cusEnts/actions/updateCusEntDbAndCache.ts:30">
P3: This cache update mapping duplicates the existing `CusEntService.syncUpdateToCache` logic. Consider reusing the existing helper or extracting a shared function to avoid divergence when the cache schema changes.</violation>
</file>

<file name="server/src/internal/balances/updateBalance/updateGrantedBalance.ts">

<violation number="1" location="server/src/internal/balances/updateBalance/updateGrantedBalance.ts:31">
P2: Making `featureId` optional without a guard allows updates to target the first entitlement when no feature or filters are provided, which can update the wrong balance. Consider requiring a featureId (or a specific filter like balance_id/cusEntId) and throwing an error when neither is supplied.</violation>
</file>

<file name="server/tests/integration/balances/update/balance/update-balance-with-id.test.ts">

<violation number="1" location="server/tests/integration/balances/update/balance/update-balance-with-id.test.ts:94">
P2: This test claims to validate `balance_id` fallback via internal id, but the update call uses a hardcoded id instead of `cusEntId`, so the fallback path is not actually exercised.</violation>
</file>

<file name="server/tests/integration/balances/create/create-balance-with-id.test.ts">

<violation number="1" location="server/tests/integration/balances/create/create-balance-with-id.test.ts:62">
P2: The duplicate-ID test claims to validate a 409 conflict but does not assert a specific error code/message, so it can pass on unrelated AutumnError failures.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.


await customerEntitlementActions.updateDbAndCache({
ctx,
customerId: fullCustomer.id ?? "",
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 4, 2026

Choose a reason for hiding this comment

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

P2: Avoid passing an empty string for customerId. If fullCustomer.id can be undefined, use a valid fallback (e.g., internal_id) or throw; otherwise updates may target an invalid customer record.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/balances/updateBalance/updateNextResetAt.ts, line 55:

<comment>Avoid passing an empty string for customerId. If `fullCustomer.id` can be undefined, use a valid fallback (e.g., internal_id) or throw; otherwise updates may target an invalid customer record.</comment>

<file context>
@@ -0,0 +1,59 @@
+
+	await customerEntitlementActions.updateDbAndCache({
+		ctx,
+		customerId: fullCustomer.id ?? "",
+		cusEntId: targetCusEnt.id,
+		updates: { next_reset_at: nextResetAt },
</file context>
Fix with Cubic

featureIds: [featureId],
entity: fullCus.entity,
fullCustomer,
featureIds: featureId ? [featureId] : undefined,
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 4, 2026

Choose a reason for hiding this comment

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

P2: Making featureId optional without a guard allows updates to target the first entitlement when no feature or filters are provided, which can update the wrong balance. Consider requiring a featureId (or a specific filter like balance_id/cusEntId) and throwing an error when neither is supplied.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/balances/updateBalance/updateGrantedBalance.ts, line 31:

<comment>Making `featureId` optional without a guard allows updates to target the first entitlement when no feature or filters are provided, which can update the wrong balance. Consider requiring a featureId (or a specific filter like balance_id/cusEntId) and throwing an error when neither is supplied.</comment>

<file context>
@@ -12,45 +11,38 @@ import {
-		featureIds: [featureId],
-		entity: fullCus.entity,
+		fullCustomer,
+		featureIds: featureId ? [featureId] : undefined,
+		entity: fullCustomer.entity,
 		inStatuses: orgToInStatuses({ org: ctx.org }),
</file context>
Fix with Cubic

customer_id: customerId,
feature_id: TestFeature.Messages,
included_grant: 100,
balance_id: "lifetime-balance",
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 4, 2026

Choose a reason for hiding this comment

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

P2: This test claims to validate balance_id fallback via internal id, but the update call uses a hardcoded id instead of cusEntId, so the fallback path is not actually exercised.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/tests/integration/balances/update/balance/update-balance-with-id.test.ts, line 94:

<comment>This test claims to validate `balance_id` fallback via internal id, but the update call uses a hardcoded id instead of `cusEntId`, so the fallback path is not actually exercised.</comment>

<file context>
@@ -0,0 +1,202 @@
+		customer_id: customerId,
+		feature_id: TestFeature.Messages,
+		included_grant: 100,
+		balance_id: "lifetime-balance",
+	});
+
</file context>
Fix with Cubic

balance_id: "dup-balance",
});

await expectAutumnError({
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 4, 2026

Choose a reason for hiding this comment

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

P2: The duplicate-ID test claims to validate a 409 conflict but does not assert a specific error code/message, so it can pass on unrelated AutumnError failures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/tests/integration/balances/create/create-balance-with-id.test.ts, line 62:

<comment>The duplicate-ID test claims to validate a 409 conflict but does not assert a specific error code/message, so it can pass on unrelated AutumnError failures.</comment>

<file context>
@@ -0,0 +1,158 @@
+		balance_id: "dup-balance",
+	});
+
+	await expectAutumnError({
+		func: async () => {
+			await autumnV2.balances.create({
</file context>
Fix with Cubic

customerId,
});

const cacheUpdates = [
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 4, 2026

Choose a reason for hiding this comment

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

P3: This cache update mapping duplicates the existing CusEntService.syncUpdateToCache logic. Consider reusing the existing helper or extracting a shared function to avoid divergence when the cache schema changes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/customers/cusProducts/cusEnts/actions/updateCusEntDbAndCache.ts, line 30:

<comment>This cache update mapping duplicates the existing `CusEntService.syncUpdateToCache` logic. Consider reusing the existing helper or extracting a shared function to avoid divergence when the cache schema changes.</comment>

<file context>
@@ -0,0 +1,53 @@
+		customerId,
+	});
+
+	const cacheUpdates = [
+		{
+			cus_ent_id: cusEntId,
</file context>
Fix with Cubic

@johnyeocx johnyeocx merged commit 0663f84 into dev Mar 4, 2026
9 of 11 checks passed
Comment on lines +3 to +25
export const DeleteBalanceParamsV0Schema = z.object({
customer_id: z.string().meta({
description: "The ID of the customer.",
}),
entity_id: z.string().optional().meta({
description: "The ID of the entity.",
}),
feature_id: z.string().optional().meta({
description: "The ID of the feature.",
}),
balance_id: z.string().optional().meta({
description: "The ID of the balance to delete.",
}),

interval: z.enum(ResetInterval).optional().meta({
description:
"Target a specific balance by its reset interval. Use when the customer has multiple balances for the same feature with different reset intervals.",
}),

customer_entitlement_id: z.string().optional().meta({
internal: true,
}),
});
Copy link
Contributor

Choose a reason for hiding this comment

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

No targeting parameter required — could delete all entitlements

Both feature_id and balance_id are optional with no schema-level .refine() enforcing that at least one is provided. A caller passing only customer_id will cause fullCustomerToCustomerEntitlements to return all entitlements for that customer (across every feature). The isPaidCustomerEntitlement guard will block paid balances, but for a customer whose entitlements are all free/loose, every single entitlement will be deleted in one call.

Consider adding a refine at the bottom of the schema:

.refine(
  (data) => data.feature_id !== undefined || data.balance_id !== undefined,
  { message: "Either feature_id or balance_id must be provided" },
)
Prompt To Fix With AI
This is a comment left during a code review.
Path: shared/api/balances/delete/deleteBalanceParams.ts
Line: 3-25

Comment:
**No targeting parameter required — could delete all entitlements**

Both `feature_id` and `balance_id` are optional with no schema-level `.refine()` enforcing that at least one is provided. A caller passing only `customer_id` will cause `fullCustomerToCustomerEntitlements` to return **all** entitlements for that customer (across every feature). The `isPaidCustomerEntitlement` guard will block paid balances, but for a customer whose entitlements are all free/loose, **every single entitlement will be deleted in one call**.

Consider adding a refine at the bottom of the schema:
```typescript
.refine(
  (data) => data.feature_id !== undefined || data.balance_id !== undefined,
  { message: "Either feature_id or balance_id must be provided" },
)
```

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant