## Summary <!-- Provide a short summary of your changes and the motivation behind them. --> ## Related Issues <!-- List any related issues, e.g. Fixes #123 or Closes #456 --> ## Type of Change - [ ] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Refactor - [ ] Other (please describe): ## Checklist - [ ] I have read the [CONTRIBUTING.md](https://github.com/useautumn/autumn/blob/staging/.github/CONTRIBUTING.md) - [ ] My code follows the code style of this project - [ ] I have added tests where applicable - [ ] I have tested my changes locally - [ ] I have linked relevant issues - [ ] I have added screenshots for UI changes (if applicable) ## Screenshots (if applicable) <!-- Add before/after screenshots or GIFs here --> ## Additional Context <!-- Add any other context or information about the PR here --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds customer-level spend limits with overage caps and an enable/disable toggle, enforced across check/track (including credit conversions) with Redis cache parity and stale-cache deletion on entity update errors. Also exposes `entities.update` in OpenAPI v2.1 and propagates `spend_limits` through DB, cache, and init/update flows. - **New Features** - Support `billing_controls.spend_limits` on customers; enforced for direct customer, inherited entity-product/per-entity balances, and credit conversions. - Reject duplicate `feature_id` entries for spend limits on customers and entities. - Atomic Redis updates for `spend_limits`; prepend `LUA_UTILS` to the customer update Lua script with `is_nil` checks. - Expose `entities.update` in OpenAPI v2.1 with docs and examples. - **Migration** - Rename types: `ApiEntityBillingControlsInput` → `ApiEntityBillingControlsParams`, `CustomerBillingControlsInput` → `CustomerBillingControlsParams`. - Update via `customers.update(customerId, { billing_controls: { spend_limits: [...] } })`; set `enabled: false` to disable a limit. - Update entity billing controls with `entities.update` using `billing_controls`. <sup>Written for commit 1f61ea39fe32b04b81ac27bebc8cd5cf29bc0723. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- greptile_comment --> <details><summary><h3>Greptile Summary</h3></summary> This PR extends the **customer billing controls** feature to support **spend limits** at the customer level, mirroring the existing entity-level spend limit functionality. The `spend_limits` field is wired through the full stack: DB schema (`cusTable`), customer init, update handler, Redis cache Lua script, API contracts (`CustomerBillingControlsParamsSchema`), and OpenAPI spec. A notable side-fix is that `LUA_UTILS` (which defines the `is_nil` helper) is now correctly prepended to `UPDATE_CUSTOMER_DATA_SCRIPT`, enabling the Lua refactor from `~= nil and ~= cjson.null` to the cleaner `is_nil()` calls. Comprehensive integration tests are added covering check, track, and CRUD scenarios across all balance types. **Key changes:** - **Improvements** — `spend_limits` column added to the `customers` DB table and propagated through `initCustomer`, `updateCustomer`, `updateCachedCustomerData`, and the Redis Lua cache script. - **Improvements** — `LUA_UTILS` is now included in `UPDATE_CUSTOMER_DATA_SCRIPT`, fixing latent missing-helper issue and enabling `is_nil()` refactor across all scalar-field nil checks in `updateCustomerData.lua`. - **API changes** — `CustomerBillingControlsParamsSchema` introduced with duplicate-`feature_id` validation for `spend_limits`; `...Input` type/schema names renamed to `...Params` for consistency across entity and customer billing control types. - **API changes** — `updateEntityContract` added to the OpenAPI v2.1 spec, exposing `POST /v1/entities.update` publicly. - **Improvements** — `expectBoundaryAndParity` test utility updated to make `entityId` optional, allowing reuse in customer-level (no-entity) check tests. - **Bug fixes** — `console.log("Customer data:", customerData)` debug statement left in `setupCreateCustomer.ts` should be removed before merge (logs potentially sensitive PII). </details> <h3>Confidence Score: 4/5</h3> - Safe to merge after removing the debug console.log in setupCreateCustomer.ts. - The feature is well-structured and closely mirrors the existing entity spend-limit and auto-topup patterns. Integration tests cover all major spend-limit scenarios (check, track, CRUD, enabled/disabled, entity-product, per-entity, credit-system). The only blocker is a leftover debug statement that logs raw customer data in production. All schema validation, cache, and DB wiring look correct. - server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomer.ts — debug console.log must be removed. <h3>Important Files Changed</h3> | Filename | Overview | |----------|----------| | server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomer.ts | Debug `console.log("Customer data:", customerData)` was accidentally left in — logs potentially sensitive PII in production. | | server/src/internal/customers/actions/update/updateCustomer.ts | Adds `spend_limits` propagation alongside `auto_topups` when billing controls are updated; relies on Drizzle/JSON.stringify omitting undefined for partial updates. | | server/src/_luaScriptsV2/customers/updateCustomerData.lua | Adds `spend_limits` cache-update block mirroring the existing `auto_topups` pattern; also refactors scalar-field nil checks to use the `is_nil()` helper from LUA_UTILS. | | server/src/_luaScriptsV2/luaScriptsV2.ts | Correctly prepends `LUA_UTILS` to `UPDATE_CUSTOMER_DATA_SCRIPT` so the `is_nil` helper is available in the Lua script that now uses it. | | shared/models/cusModels/billingControls/customerBillingControls.ts | Introduces `CustomerBillingControlsParamsSchema` with duplicate `feature_id` validation for `spend_limits`; renames `...Input` type to `...Params` for consistency. | | shared/models/cusModels/cusTable.ts | Adds a `spend_limits` JSONB column typed as `DbSpendLimit[]` to the `customers` Drizzle table definition. | | shared/api/billingControls/entityBillingControls.ts | Renames `ApiEntityBillingControlsInputSchema` → `ApiEntityBillingControlsParamsSchema` and migrates duplicate-feature-id validation from `.superRefine()` to Zod v4's `.check()` API. | | packages/openapi/v2.1/contracts/entitiesContract.ts | Adds `updateEntityContract` exposing the `POST /v1/entities.update` endpoint in the OpenAPI spec. | | server/tests/integration/crud/customers/customer-billing-controls.test.ts | New integration tests covering customer spend-limit CRUD: create with spend limits, update without clearing other controls, and duplicate-feature-id rejection. | | server/tests/integration/balances/check/spend-limit/check-customer-spend-limit.test.ts | New integration tests verifying that customer-level spend limits are respected on check calls across all balance types (lifetime, prepaid, consumable, entity-product, per-entity) and that disabled limits lift the cap. | | server/tests/integration/balances/track/spend-limit/track-customer-spend-limit.test.ts | New integration tests verifying that customer-level spend limits cap overage on track calls, including credit-system conversion and disabled-limit bypass. | </details> <details><summary><h3>Sequence Diagram</h3></summary> ```mermaid sequenceDiagram participant Client participant API as API (updateCustomer / createCustomer) participant DB as PostgreSQL (customers) participant Cache as Redis (FullCustomer) participant Lua as Lua (updateCustomerData.lua) Client->>API: PATCH /customers/:id { billing_controls: { spend_limits: [...] } } API->>API: Validate via CustomerBillingControlsParamsSchema\n(dedup feature_ids) API->>DB: CusService.update({ spend_limits: [...] }) DB-->>API: OK API->>Cache: updateCachedCustomerData({ spend_limits: [...] }) Cache->>Lua: JSON.stringify({ updates: { spend_limits: [...] } }) Lua->>Cache: JSON.SET $.spend_limits Cache-->>API: { success: true, updated_fields: ["spend_limits"] } API-->>Client: Updated customer Note over API,DB: auto_topups left untouched when only spend_limits is provided\n(undefined skipped by Drizzle + JSON.stringify) ``` </details> <sub>Last reviewed commit: 7d2b44a</sub> > Greptile also left **2 inline comments** on this PR. <!-- /greptile_comment -->
69 lines
2.0 KiB
TypeScript
69 lines
2.0 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import {
|
|
boolean,
|
|
foreignKey,
|
|
index,
|
|
jsonb,
|
|
numeric,
|
|
pgTable,
|
|
text,
|
|
unique,
|
|
uniqueIndex,
|
|
} from "drizzle-orm/pg-core";
|
|
import { collatePgColumn } from "../../db/utils.js";
|
|
import type { ExternalProcessors } from "../genModels/processorSchemas.js";
|
|
import { organizations } from "../orgModels/orgTable.js";
|
|
import type {
|
|
AutoTopup,
|
|
DbSpendLimit,
|
|
} from "./billingControls/customerBillingControls.js";
|
|
|
|
export type CustomerProcessor = {
|
|
type: "stripe";
|
|
id: string;
|
|
};
|
|
|
|
export const customers = pgTable(
|
|
"customers",
|
|
{
|
|
internal_id: text("internal_id").primaryKey().notNull(),
|
|
org_id: text("org_id").notNull(),
|
|
created_at: numeric({ mode: "number" }).notNull(),
|
|
name: text(),
|
|
id: text(),
|
|
email: text(),
|
|
fingerprint: text().default(sql`null`),
|
|
metadata: jsonb().$type<Record<string, unknown>>(),
|
|
env: text().notNull(),
|
|
processor: jsonb().$type<CustomerProcessor>(),
|
|
processors: jsonb()
|
|
.$type<ExternalProcessors>()
|
|
.default({} as ExternalProcessors),
|
|
send_email_receipts: boolean("send_email_receipts").default(false),
|
|
auto_topups: jsonb().$type<AutoTopup[]>(),
|
|
spend_limits: jsonb().$type<DbSpendLimit[]>(),
|
|
},
|
|
(table) => [
|
|
unique("cus_id_constraint").on(table.org_id, table.id, table.env),
|
|
foreignKey({
|
|
columns: [table.org_id],
|
|
foreignColumns: [organizations.id],
|
|
name: "customers_org_id_fkey",
|
|
}).onDelete("cascade"),
|
|
// Ensure only ONE customer per (org, env, email) can have id = NULL
|
|
uniqueIndex("customers_email_null_id_unique")
|
|
.on(table.org_id, table.env, sql`lower(${table.email})`)
|
|
.where(
|
|
sql`${table.id} IS NULL AND ${table.email} IS NOT NULL AND ${table.email} != ''`,
|
|
),
|
|
index("idx_customers_org_env_fingerprint")
|
|
.on(table.org_id, table.env, table.fingerprint)
|
|
.where(sql`${table.fingerprint} IS NOT NULL`),
|
|
],
|
|
).enableRLS();
|
|
|
|
collatePgColumn(customers.internal_id, "C");
|
|
|
|
// CREATE INDEX idx_customers_org_env_internal_id
|
|
// ON customers (org_id, env, internal_id DESC);
|