From f4c7c153225db46d25b1682661ec04863b0ff1a0 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 13:56:58 +0200 Subject: [PATCH 01/29] server: Add manual credit granting on customer meters --- server/polar/customer_meter/auth.py | 12 + server/polar/customer_meter/endpoints.py | 44 +++- server/polar/customer_meter/schemas.py | 30 +++ server/polar/customer_meter/service.py | 131 +++++++++- server/polar/declarative_config/endpoints.py | 2 +- server/polar/declarative_config/exceptions.py | 6 + server/polar/declarative_config/service.py | 6 +- server/polar/event/repository.py | 9 + server/tests/customer_meter/test_endpoints.py | 230 ++++++++++++++++++ .../tests/declarative_config/test_service.py | 2 +- 10 files changed, 461 insertions(+), 11 deletions(-) create mode 100644 server/polar/declarative_config/exceptions.py diff --git a/server/polar/customer_meter/auth.py b/server/polar/customer_meter/auth.py index f31447a76e9..6c331c9152e 100644 --- a/server/polar/customer_meter/auth.py +++ b/server/polar/customer_meter/auth.py @@ -13,6 +13,18 @@ }, allowed_subjects={User, Organization}, ) + +_CustomerMeterWrite = Authenticator( + required_scopes={ + Scope.customers_write, + }, + allowed_subjects={User, Organization}, +) + CustomerMeterRead = Annotated[ AuthSubject[User | Organization], Depends(_CustomerMeterRead) ] + +CustomerMeterWrite = Annotated[ + AuthSubject[User | Organization], Depends(_CustomerMeterWrite) +] diff --git a/server/polar/customer_meter/endpoints.py b/server/polar/customer_meter/endpoints.py index b59b5968cc2..4982dcc291d 100644 --- a/server/polar/customer_meter/endpoints.py +++ b/server/polar/customer_meter/endpoints.py @@ -1,7 +1,8 @@ from fastapi import Depends, Query from polar.customer.schemas.customer import CustomerID -from polar.exceptions import ResourceNotFound +from polar.declarative_config.exceptions import ConfigAsCodeNotEnabled +from polar.exceptions import NotPermitted, ResourceNotFound from polar.kit.pagination import ListResource, PaginationParamsQuery from polar.kit.schemas import MultipleQueryFilter from polar.kit.versioning import version @@ -15,7 +16,11 @@ from . import auth, sorting from .schemas import CustomerMeter as CustomerMeterSchema -from .schemas import CustomerMeterID +from .schemas import CustomerMeterCreditsCreate, CustomerMeterID +from .service import ( + CustomerMeterCreditConcurrentRequest, + CustomerMeterCreditExternalIDConflict, +) from .service import customer_meter as customer_meter_service router = APIRouter( @@ -144,3 +149,38 @@ async def get( raise ResourceNotFound() return customer_meter + + +@router.post( + "/credits", + summary="Credit Customer Meter", + response_model=CustomerMeterSchema, + status_code=201, + responses={ + 201: {"description": "Credits granted."}, + 403: { + "description": ( + "You don't have permission to manage customers, " + "or config as code is not enabled for this organization." + ), + "model": NotPermitted.schema() | ConfigAsCodeNotEnabled.schema(), + }, + 409: { + "description": ( + "The external_id is already used by a different event, " + "or by a concurrent request." + ), + "model": CustomerMeterCreditExternalIDConflict.schema() + | CustomerMeterCreditConcurrentRequest.schema(), + }, + }, + tags=[APITag.private], +) +@version(starting_from=V2027_01) +async def credits( + credit_create: CustomerMeterCreditsCreate, + auth_subject: auth.CustomerMeterWrite, + session: AsyncSession = Depends(get_db_session), +) -> CustomerMeter: + """Manually grant credits on a customer meter.""" + return await customer_meter_service.credit(session, auth_subject, credit_create) diff --git a/server/polar/customer_meter/schemas.py b/server/polar/customer_meter/schemas.py index 274be2ce0a2..0ad0f9d3c34 100644 --- a/server/polar/customer_meter/schemas.py +++ b/server/polar/customer_meter/schemas.py @@ -8,6 +8,7 @@ CUSTOMER_ID_EXAMPLE, METER_ID_EXAMPLE, IDSchema, + Schema, TimestampedSchema, ) from polar.meter.schemas import Meter @@ -42,3 +43,32 @@ class CustomerMeter(CustomerMeterBase): customer: Customer = Field(description="The customer associated with this meter.") meter: Meter = Field(description="The meter associated with this customer.") + + +class CustomerMeterCreditsCreate(Schema): + """Schema to manually grant credits on a customer meter.""" + + customer_id: UUID4 = Field( + description="The ID of the customer.", examples=[CUSTOMER_ID_EXAMPLE] + ) + meter_id: UUID4 = Field( + description="The ID of the meter.", examples=[METER_ID_EXAMPLE] + ) + units: int = Field( + gt=0, + le=1_000_000_000, + description="The number of units to credit.", + examples=[100], + ) + rollover: bool = Field( + default=False, + description="Whether unused credits carry over to the next meter period.", + ) + external_id: str | None = Field( + default=None, + min_length=1, + description=( + "Your unique identifier for this credit. " + "Retrying with the same `external_id` doesn't grant the credit twice." + ), + ) diff --git a/server/polar/customer_meter/service.py b/server/polar/customer_meter/service.py index 3218517f58f..af3ca808946 100644 --- a/server/polar/customer_meter/service.py +++ b/server/polar/customer_meter/service.py @@ -6,14 +6,24 @@ import logfire from sqlalchemy import Float, Select, func, or_, select, union_all -from sqlalchemy.exc import DBAPIError +from sqlalchemy.exc import DBAPIError, IntegrityError from sqlalchemy.orm import joinedload from sqlalchemy.orm.strategy_options import contains_eager from polar.auth.models import AuthSubject, Organization, User -from polar.authz.service import get_accessible_org_ids +from polar.auth.permission import OrganizationPermission +from polar.authz.service import assert_organization_permission, get_accessible_org_ids from polar.config import settings +from polar.customer.repository import CustomerRepository +from polar.declarative_config.exceptions import ConfigAsCodeNotEnabled from polar.event.repository import EventRepository +from polar.event.service import event as event_service +from polar.event.system import ( + MeterCreditedMetadata, + SystemEvent, + build_system_event, +) +from polar.exceptions import PolarError, PolarRequestValidationError from polar.kit.db.locking import is_lock_not_available_error from polar.kit.math import non_negative_running_sum from polar.kit.pagination import PaginationParams @@ -32,9 +42,24 @@ from polar.worker import enqueue_job from .repository import CustomerMeterRepository +from .schemas import CustomerMeterCreditsCreate from .sorting import CustomerMeterSortProperty +class CustomerMeterCreditExternalIDConflict(PolarError): + def __init__(self) -> None: + super().__init__("This external_id is already used by a different event.", 409) + + +class CustomerMeterCreditConcurrentRequest(PolarError): + def __init__(self) -> None: + super().__init__( + "A credit with this external_id was created by a concurrent request. " + "Retry.", + 409, + ) + + class CustomerMeterService: async def list( self, @@ -204,6 +229,108 @@ async def update_customer_meter( return await repository.update(customer_meter), True + async def credit( + self, + session: AsyncSession, + auth_subject: AuthSubject[User | Organization], + credit_create: CustomerMeterCreditsCreate, + ) -> CustomerMeter: + org_ids = await get_accessible_org_ids(session, auth_subject) + + customer_repository = CustomerRepository.from_session(session) + customer = await customer_repository.get_readable_by_id( + org_ids, + credit_create.customer_id, + options=(joinedload(Customer.organization),), + ) + if customer is None: + raise PolarRequestValidationError( + [ + { + "type": "value_error", + "loc": ("body", "customer_id"), + "msg": "Customer not found", + "input": credit_create.customer_id, + } + ] + ) + + meter_repository = MeterRepository.from_session(session) + meter = await meter_repository.get_readable_by_id( + credit_create.meter_id, org_ids + ) + + if ( + meter is None + or meter.archived_at is not None + or meter.organization_id != customer.organization_id + ): + raise PolarRequestValidationError( + [ + { + "type": "value_error", + "loc": ("body", "meter_id"), + "msg": "Meter not found", + "input": credit_create.meter_id, + } + ] + ) + + await assert_organization_permission( + session, + auth_subject, + customer.organization_id, + OrganizationPermission.customers_manage, + ) + if not customer.organization.is_config_as_code_enabled: + raise ConfigAsCodeNotEnabled() + + metadata: MeterCreditedMetadata = { + "meter_id": str(meter.id), + "units": credit_create.units, + "rollover": credit_create.rollover, + } + existing_event = None + if credit_create.external_id is not None: + event_repository = EventRepository.from_session(session) + existing_event = await event_repository.get_by_organization_and_external_id( + customer.organization_id, credit_create.external_id + ) + if existing_event is not None and ( + existing_event.name != SystemEvent.meter_credited + or existing_event.customer_id != customer.id + or existing_event.user_metadata != metadata + ): + raise CustomerMeterCreditExternalIDConflict() + + if existing_event is None: + event = build_system_event( + SystemEvent.meter_credited, + customer=customer, + organization=customer.organization, + metadata=metadata, + ) + event.external_id = credit_create.external_id + try: + async with session.begin_nested(): + await event_service.create_event(session, event) + except IntegrityError as e: + if credit_create.external_id is None: + raise + raise CustomerMeterCreditConcurrentRequest() from e + + customer_meter, updated = await self.update_customer_meter( + session, customer, meter, activate_meter=True + ) + assert customer_meter is not None + + if updated: + enqueue_job("customer.state_changed", customer.id) + + loaded = await self.get(session, auth_subject, customer_meter.id) + assert loaded is not None + return loaded + async def get_rollover_units( self, session: AsyncSession, diff --git a/server/polar/declarative_config/endpoints.py b/server/polar/declarative_config/endpoints.py index 5a38e0df556..17504ca2f5a 100644 --- a/server/polar/declarative_config/endpoints.py +++ b/server/polar/declarative_config/endpoints.py @@ -13,9 +13,9 @@ from polar.redis import Redis, get_redis from polar.routing import APIRouter +from .exceptions import ConfigAsCodeNotEnabled from .schemas import Config, ConfigApplyResult, ConfigExport, ConfigPlan from .service import ( - ConfigAsCodeNotEnabled, ConfigBenefitConflict, ConfigInvalid, ConfigMeterConflict, diff --git a/server/polar/declarative_config/exceptions.py b/server/polar/declarative_config/exceptions.py new file mode 100644 index 00000000000..f1148e5b9a1 --- /dev/null +++ b/server/polar/declarative_config/exceptions.py @@ -0,0 +1,6 @@ +from polar.exceptions import PolarError + + +class ConfigAsCodeNotEnabled(PolarError): + def __init__(self) -> None: + super().__init__("Config as code is not enabled for this organization.", 403) diff --git a/server/polar/declarative_config/service.py b/server/polar/declarative_config/service.py index 9248b24fd47..3d5f5924643 100644 --- a/server/polar/declarative_config/service.py +++ b/server/polar/declarative_config/service.py @@ -25,6 +25,7 @@ from polar.redis import Redis from . import benefits, discounts, products, validation +from .exceptions import ConfigAsCodeNotEnabled from .schemas import ( MAXIMUM_METERS, Config, @@ -83,11 +84,6 @@ class _OrganizationTarget: organization_id: UUID | None -class ConfigAsCodeNotEnabled(PolarError): - def __init__(self) -> None: - super().__init__("Config as code is not enabled for this organization.", 403) - - class ConfigMeterConflict(PolarError): def __init__(self) -> None: super().__init__( diff --git a/server/polar/event/repository.py b/server/polar/event/repository.py index 35c2a8b77be..201e9ee59e2 100644 --- a/server/polar/event/repository.py +++ b/server/polar/event/repository.py @@ -515,6 +515,15 @@ def get_by_pending_entries_for_meter_statement( def get_eager_options(self) -> Options: return (joinedload(Event.customer), joinedload(Event.event_types)) + async def get_by_organization_and_external_id( + self, organization_id: UUID, external_id: str + ) -> Event | None: + statement = self.get_base_statement().where( + Event.organization_id == organization_id, + Event.external_id == external_id, + ) + return await self.get_one_or_none(statement) + async def get_by_id_with_eager(self, id: UUID) -> Event | None: statement = ( self.get_base_statement() diff --git a/server/tests/customer_meter/test_endpoints.py b/server/tests/customer_meter/test_endpoints.py index 481a5aea1fa..70ed93e2853 100644 --- a/server/tests/customer_meter/test_endpoints.py +++ b/server/tests/customer_meter/test_endpoints.py @@ -4,13 +4,17 @@ import pytest from httpx import AsyncClient +from polar.kit.utils import utc_now from polar.models import ( Customer, CustomerMeter, + Meter, Organization, + User, UserOrganization, ) from polar.version import V2027_01 +from tests.fixtures.auth import AuthSubjectFixture from tests.fixtures.database import SaveFixture from tests.fixtures.random_objects import create_meter @@ -37,6 +41,17 @@ async def customer_meter_organization_second( return customer_meter +@pytest.fixture +async def config_as_code_enabled( + save_fixture: SaveFixture, organization: Organization +) -> None: + organization.feature_settings = { + **organization.feature_settings, + "config_as_code_enabled": True, + } + await save_fixture(organization) + + @pytest.fixture async def customer_meters_with_external_ids( save_fixture: SaveFixture, organization: Organization, customer: Customer @@ -120,3 +135,218 @@ async def test_user_cannot_access_other_organization_customer_meter( ) assert response.status_code == 404 + + +@pytest.mark.anyio +class TestCreditCustomerMeter: + @pytest.mark.api_version(V2027_01) + async def test_anonymous_2027_01(self, client: AsyncClient) -> None: + response = await client.post( + "/v1/customer-meters/credits", + json={ + "customer_id": str(uuid.uuid4()), + "meter_id": str(uuid.uuid4()), + "units": 100, + }, + ) + + assert response.status_code == 401 + + @pytest.mark.api_version(V2027_01) + @pytest.mark.auth + async def test_customer_not_found( + self, + client: AsyncClient, + user_organization: UserOrganization, + meter: Meter, + ) -> None: + response = await client.post( + "/v1/customer-meters/credits", + json={ + "customer_id": str(uuid.uuid4()), + "meter_id": str(meter.id), + "units": 100, + }, + ) + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["body", "customer_id"] + + @pytest.mark.api_version(V2027_01) + @pytest.mark.auth + async def test_meter_not_found( + self, + client: AsyncClient, + user_organization: UserOrganization, + customer: Customer, + ) -> None: + response = await client.post( + "/v1/customer-meters/credits", + json={ + "customer_id": str(customer.id), + "meter_id": str(uuid.uuid4()), + "units": 100, + }, + ) + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["body", "meter_id"] + + @pytest.mark.api_version(V2027_01) + @pytest.mark.auth + async def test_meter_from_other_accessible_organization( + self, + save_fixture: SaveFixture, + client: AsyncClient, + user: User, + user_organization: UserOrganization, + organization_second: Organization, + customer: Customer, + customer_meter_organization_second: CustomerMeter, + ) -> None: + await save_fixture( + UserOrganization(user=user, organization=organization_second) + ) + + response = await client.post( + "/v1/customer-meters/credits", + json={ + "customer_id": str(customer.id), + "meter_id": str(customer_meter_organization_second.meter_id), + "units": 100, + }, + ) + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["body", "meter_id"] + + @pytest.mark.api_version(V2027_01) + @pytest.mark.auth + async def test_archived_meter( + self, + save_fixture: SaveFixture, + client: AsyncClient, + user_organization: UserOrganization, + customer: Customer, + meter: Meter, + ) -> None: + meter.archived_at = utc_now() + await save_fixture(meter) + + response = await client.post( + "/v1/customer-meters/credits", + json={ + "customer_id": str(customer.id), + "meter_id": str(meter.id), + "units": 100, + }, + ) + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["body", "meter_id"] + + @pytest.mark.api_version(V2027_01) + @pytest.mark.auth + async def test_config_as_code_not_enabled( + self, + client: AsyncClient, + user_organization: UserOrganization, + customer: Customer, + meter: Meter, + ) -> None: + response = await client.post( + "/v1/customer-meters/credits", + json={ + "customer_id": str(customer.id), + "meter_id": str(meter.id), + "units": 100, + }, + ) + + assert response.status_code == 403 + assert response.json()["error"] == "ConfigAsCodeNotEnabled" + + @pytest.mark.api_version(V2027_01) + @pytest.mark.auth( + AuthSubjectFixture(subject="user"), + AuthSubjectFixture(subject="organization"), + ) + @pytest.mark.usefixtures("config_as_code_enabled") + async def test_valid( + self, + client: AsyncClient, + user_organization: UserOrganization, + customer: Customer, + meter: Meter, + ) -> None: + response = await client.post( + "/v1/customer-meters/credits", + json={ + "customer_id": str(customer.id), + "meter_id": str(meter.id), + "units": 100, + "rollover": True, + }, + ) + + assert response.status_code == 201 + json = response.json() + assert json["customer_id"] == str(customer.id) + assert json["meter_id"] == str(meter.id) + assert json["credited_units"] == 100 + assert json["consumed_units"] == 0.0 + assert json["balance"] == 100.0 + assert json["customer"]["id"] == str(customer.id) + assert json["meter"]["id"] == str(meter.id) + + @pytest.mark.api_version(V2027_01) + @pytest.mark.auth + @pytest.mark.usefixtures("config_as_code_enabled") + async def test_external_id_replay( + self, + client: AsyncClient, + user_organization: UserOrganization, + customer: Customer, + meter: Meter, + ) -> None: + payload = { + "customer_id": str(customer.id), + "meter_id": str(meter.id), + "units": 100, + "external_id": "credit_1", + } + + first = await client.post("/v1/customer-meters/credits", json=payload) + second = await client.post("/v1/customer-meters/credits", json=payload) + + assert first.status_code == 201 + assert second.status_code == 201 + assert second.json()["id"] == first.json()["id"] + assert second.json()["credited_units"] == 100 + assert second.json()["balance"] == 100.0 + + @pytest.mark.api_version(V2027_01) + @pytest.mark.auth + @pytest.mark.usefixtures("config_as_code_enabled") + async def test_external_id_conflict( + self, + client: AsyncClient, + user_organization: UserOrganization, + customer: Customer, + meter: Meter, + ) -> None: + payload = { + "customer_id": str(customer.id), + "meter_id": str(meter.id), + "units": 100, + "external_id": "credit_1", + } + + first = await client.post("/v1/customer-meters/credits", json=payload) + second = await client.post( + "/v1/customer-meters/credits", json={**payload, "units": 200} + ) + + assert first.status_code == 201 + assert second.status_code == 409 + assert second.json()["error"] == "CustomerMeterCreditExternalIDConflict" diff --git a/server/tests/declarative_config/test_service.py b/server/tests/declarative_config/test_service.py index 64e11a5622b..2b5a98b1138 100644 --- a/server/tests/declarative_config/test_service.py +++ b/server/tests/declarative_config/test_service.py @@ -7,6 +7,7 @@ from polar.auth.models import AuthSubject from polar.auth.scope import Scope from polar.benefit.repository import BenefitRepository +from polar.declarative_config.exceptions import ConfigAsCodeNotEnabled from polar.declarative_config.schemas import ( Config, ConfigAction, @@ -15,7 +16,6 @@ ConfigResource, ) from polar.declarative_config.service import ( - ConfigAsCodeNotEnabled, ConfigBenefitConflict, ConfigInvalid, ConfigMeterConflict, From eca6325dbf2e02eedaf99a81a5bf45a704cc774a Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 14:01:22 +0200 Subject: [PATCH 02/29] server: Make credit customer meter endpoint public, regenerate client and docs --- clients/packages/client/src/v1.ts | 136 +++++++++++++ docs/docs.json | 6 +- docs/openapi/2027-01.openapi.json | 239 +++++++++++++++++++++++ server/polar/customer_meter/endpoints.py | 1 - 4 files changed, 380 insertions(+), 2 deletions(-) diff --git a/clients/packages/client/src/v1.ts b/clients/packages/client/src/v1.ts index c53a79b537f..eaa8faadd3a 100644 --- a/clients/packages/client/src/v1.ts +++ b/clients/packages/client/src/v1.ts @@ -6206,6 +6206,28 @@ export interface paths { patch?: never trace?: never } + '/v1/customer-meters/credits': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** + * Credit Customer Meter + * @description Manually grant credits on a customer meter. + * + * **Scopes**: `customers:write` + */ + post: operations['customer_meters:credits'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/v1/payments/': { parameters: { query?: never @@ -19160,6 +19182,65 @@ export interface components { /** @description The meter associated with this customer. */ meter: components['schemas']['Meter'] } + /** CustomerMeterCreditConcurrentRequest */ + CustomerMeterCreditConcurrentRequest: { + /** + * Error + * @example CustomerMeterCreditConcurrentRequest + * @constant + */ + error: 'CustomerMeterCreditConcurrentRequest' + /** Detail */ + detail: string + } + /** CustomerMeterCreditExternalIDConflict */ + CustomerMeterCreditExternalIDConflict: { + /** + * Error + * @example CustomerMeterCreditExternalIDConflict + * @constant + */ + error: 'CustomerMeterCreditExternalIDConflict' + /** Detail */ + detail: string + } + /** + * CustomerMeterCreditsCreate + * @description Schema to manually grant credits on a customer meter. + */ + CustomerMeterCreditsCreate: { + /** + * Customer Id + * Format: uuid4 + * @description The ID of the customer. + * @example 992fae2a-2a17-4b7a-8d9e-e287cf90131b + */ + customer_id: string + /** + * Meter Id + * Format: uuid4 + * @description The ID of the meter. + * @example d498a884-e2cd-4d3e-8002-f536468a8b22 + */ + meter_id: string + /** + * Units + * @description The number of units to credit. + * @example 100 + */ + units: number + /** + * Rollover + * @description Whether unused credits carry over to the next meter period. + * @default false + */ + rollover: boolean + /** + * External Id + * @description Your unique identifier for this credit. Retrying with the same `external_id` doesn't grant the credit twice. + */ + external_id?: string | null + } /** * CustomerMeterSortProperty * @enum {string} @@ -63115,6 +63196,61 @@ export interface operations { } } } + 'customer_meters:credits': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['CustomerMeterCreditsCreate'] + } + } + responses: { + /** @description Credits granted. */ + 201: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['CustomerMeter'] + } + } + /** @description You don't have permission to manage customers, or config as code is not enabled for this organization. */ + 403: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': + | components['schemas']['NotPermitted'] + | components['schemas']['ConfigAsCodeNotEnabled'] + } + } + /** @description The external_id is already used by a different event, or by a concurrent request. */ + 409: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': + | components['schemas']['CustomerMeterCreditExternalIDConflict'] + | components['schemas']['CustomerMeterCreditConcurrentRequest'] + } + } + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['HTTPValidationError'] + } + } + } + } 'payments:list': { parameters: { query?: { diff --git a/docs/docs.json b/docs/docs.json index 7c331b34f5a..f42252e6357 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1173,7 +1173,11 @@ }, { "group": "Customer Meters", - "pages": ["GET /v1/customer-meters/{id}", "GET /v1/customer-meters/"] + "pages": [ + "GET /v1/customer-meters/{id}", + "GET /v1/customer-meters/", + "POST /v1/customer-meters/credits" + ] }, { "group": "License Keys", diff --git a/docs/openapi/2027-01.openapi.json b/docs/openapi/2027-01.openapi.json index 837d0cdfd56..eddd516bd9b 100644 --- a/docs/openapi/2027-01.openapi.json +++ b/docs/openapi/2027-01.openapi.json @@ -25371,6 +25371,120 @@ ] } }, + "/v1/customer-meters/credits": { + "post": { + "tags": [ + "customer_meters", + "public", + "mcp", + "cli" + ], + "summary": "Credit Customer Meter", + "description": "Manually grant credits on a customer meter.\n\n**Scopes**: `customers:write`", + "operationId": "customer_meters:credits", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerMeterCreditsCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Credits granted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerMeter" + } + } + } + }, + "403": { + "description": "You don't have permission to manage customers, or config as code is not enabled for this organization.", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/NotPermitted" + }, + { + "$ref": "#/components/schemas/ConfigAsCodeNotEnabled" + } + ], + "title": "Response 403 Customer Meters:Credits" + } + } + } + }, + "409": { + "description": "The external_id is already used by a different event, or by a concurrent request.", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CustomerMeterCreditExternalIDConflict" + }, + { + "$ref": "#/components/schemas/CustomerMeterCreditConcurrentRequest" + } + ], + "title": "Response 409 Customer Meters:Credits" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "oidc": [ + "customers:write" + ] + }, + { + "pat": [ + "customers:write" + ] + }, + { + "oat": [ + "customers:write" + ] + } + ], + "x-polar-allowed-subjects": [ + "Organization", + "User" + ], + "x-speakeasy-group": "customer_meters", + "x-speakeasy-name-override": "credits", + "x-codeSamples": [ + { + "lang": "python", + "source": "from polar.v2027_01 import Polar\n\npolar = Polar(\"polar_oat_xxx\")\n\nresponse = polar.customer_meters.credits(\n customer_id='992fae2a-2a17-4b7a-8d9e-e287cf90131b',\n meter_id='d498a884-e2cd-4d3e-8002-f536468a8b22',\n units=100,\n rollover=False,\n)\nprint(response)\n" + }, + { + "lang": "typescript", + "source": "import { createPolar } from \"@polar-sh/sdk/2027-01\";\n\nconst polar = createPolar({\n accessToken: \"polar_oat_xxx\",\n});\n\nconst response = await polar.customerMeters.credits(\n {\n \"customer_id\": \"992fae2a-2a17-4b7a-8d9e-e287cf90131b\",\n \"meter_id\": \"d498a884-e2cd-4d3e-8002-f536468a8b22\",\n \"units\": 100,\n \"rollover\": false\n },\n);\nconsole.log(response);\n" + } + ] + } + }, "/v1/payments/": { "get": { "tags": [ @@ -44371,6 +44485,28 @@ "title": "CheckoutUpdatePublic", "description": "Update an existing checkout session using the client secret." }, + "ConfigAsCodeNotEnabled": { + "properties": { + "error": { + "type": "string", + "const": "ConfigAsCodeNotEnabled", + "title": "Error", + "examples": [ + "ConfigAsCodeNotEnabled" + ] + }, + "detail": { + "type": "string", + "title": "Detail" + } + }, + "type": "object", + "required": [ + "error", + "detail" + ], + "title": "ConfigAsCodeNotEnabled" + }, "CostMetadata-Input": { "properties": { "amount": { @@ -49138,6 +49274,109 @@ "title": "CustomerMeter", "description": "An active customer meter, with current consumed and credited units." }, + "CustomerMeterCreditConcurrentRequest": { + "properties": { + "error": { + "type": "string", + "const": "CustomerMeterCreditConcurrentRequest", + "title": "Error", + "examples": [ + "CustomerMeterCreditConcurrentRequest" + ] + }, + "detail": { + "type": "string", + "title": "Detail" + } + }, + "type": "object", + "required": [ + "error", + "detail" + ], + "title": "CustomerMeterCreditConcurrentRequest" + }, + "CustomerMeterCreditExternalIDConflict": { + "properties": { + "error": { + "type": "string", + "const": "CustomerMeterCreditExternalIDConflict", + "title": "Error", + "examples": [ + "CustomerMeterCreditExternalIDConflict" + ] + }, + "detail": { + "type": "string", + "title": "Detail" + } + }, + "type": "object", + "required": [ + "error", + "detail" + ], + "title": "CustomerMeterCreditExternalIDConflict" + }, + "CustomerMeterCreditsCreate": { + "properties": { + "customer_id": { + "type": "string", + "format": "uuid4", + "title": "Customer Id", + "description": "The ID of the customer.", + "examples": [ + "992fae2a-2a17-4b7a-8d9e-e287cf90131b" + ] + }, + "meter_id": { + "type": "string", + "format": "uuid4", + "title": "Meter Id", + "description": "The ID of the meter.", + "examples": [ + "d498a884-e2cd-4d3e-8002-f536468a8b22" + ] + }, + "units": { + "type": "integer", + "maximum": 1000000000.0, + "exclusiveMinimum": 0.0, + "title": "Units", + "description": "The number of units to credit.", + "examples": [ + 100 + ] + }, + "rollover": { + "type": "boolean", + "title": "Rollover", + "description": "Whether unused credits carry over to the next meter period.", + "default": false + }, + "external_id": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "External Id", + "description": "Your unique identifier for this credit. Retrying with the same `external_id` doesn't grant the credit twice." + } + }, + "type": "object", + "required": [ + "customer_id", + "meter_id", + "units" + ], + "title": "CustomerMeterCreditsCreate", + "description": "Schema to manually grant credits on a customer meter." + }, "CustomerMeterSortProperty": { "type": "string", "enum": [ diff --git a/server/polar/customer_meter/endpoints.py b/server/polar/customer_meter/endpoints.py index 4982dcc291d..52bedf69fdb 100644 --- a/server/polar/customer_meter/endpoints.py +++ b/server/polar/customer_meter/endpoints.py @@ -174,7 +174,6 @@ async def get( | CustomerMeterCreditConcurrentRequest.schema(), }, }, - tags=[APITag.private], ) @version(starting_from=V2027_01) async def credits( From 5bc7b5cbd583d36610bdd1db2c7403fd1a2bbb39 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 14:08:37 +0200 Subject: [PATCH 03/29] server: Don't treat user-ingested meter.credited events as credit replays --- server/polar/customer_meter/service.py | 3 +- server/tests/customer_meter/test_endpoints.py | 37 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/server/polar/customer_meter/service.py b/server/polar/customer_meter/service.py index af3ca808946..514effcdab1 100644 --- a/server/polar/customer_meter/service.py +++ b/server/polar/customer_meter/service.py @@ -297,7 +297,8 @@ async def credit( customer.organization_id, credit_create.external_id ) if existing_event is not None and ( - existing_event.name != SystemEvent.meter_credited + existing_event.source != EventSource.system + or existing_event.name != SystemEvent.meter_credited or existing_event.customer_id != customer.id or existing_event.user_metadata != metadata ): diff --git a/server/tests/customer_meter/test_endpoints.py b/server/tests/customer_meter/test_endpoints.py index 70ed93e2853..de12cfb8531 100644 --- a/server/tests/customer_meter/test_endpoints.py +++ b/server/tests/customer_meter/test_endpoints.py @@ -4,6 +4,7 @@ import pytest from httpx import AsyncClient +from polar.event.system import SystemEvent from polar.kit.utils import utc_now from polar.models import ( Customer, @@ -16,7 +17,7 @@ from polar.version import V2027_01 from tests.fixtures.auth import AuthSubjectFixture from tests.fixtures.database import SaveFixture -from tests.fixtures.random_objects import create_meter +from tests.fixtures.random_objects import create_event, create_meter @pytest.fixture @@ -350,3 +351,37 @@ async def test_external_id_conflict( assert first.status_code == 201 assert second.status_code == 409 assert second.json()["error"] == "CustomerMeterCreditExternalIDConflict" + + @pytest.mark.api_version(V2027_01) + @pytest.mark.auth + @pytest.mark.usefixtures("config_as_code_enabled") + async def test_external_id_used_by_user_event( + self, + save_fixture: SaveFixture, + client: AsyncClient, + user_organization: UserOrganization, + organization: Organization, + customer: Customer, + meter: Meter, + ) -> None: + await create_event( + save_fixture, + organization=organization, + customer=customer, + name=SystemEvent.meter_credited, + external_id="credit_1", + metadata={"meter_id": str(meter.id), "units": 100, "rollover": False}, + ) + + response = await client.post( + "/v1/customer-meters/credits", + json={ + "customer_id": str(customer.id), + "meter_id": str(meter.id), + "units": 100, + "external_id": "credit_1", + }, + ) + + assert response.status_code == 409 + assert response.json()["error"] == "CustomerMeterCreditExternalIDConflict" From a6ffe1d054123d2a0a43b21e88d95c05beec35f5 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 14:15:00 +0200 Subject: [PATCH 04/29] docs: Drop credit customer meter page from navigation --- docs/docs.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index f42252e6357..7c331b34f5a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1173,11 +1173,7 @@ }, { "group": "Customer Meters", - "pages": [ - "GET /v1/customer-meters/{id}", - "GET /v1/customer-meters/", - "POST /v1/customer-meters/credits" - ] + "pages": ["GET /v1/customer-meters/{id}", "GET /v1/customer-meters/"] }, { "group": "License Keys", From 274391a9aaeabcaee8a8dc73e594f9f72f921c36 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 14:40:22 +0200 Subject: [PATCH 05/29] server: Accept external_customer_id when crediting customer meters --- clients/packages/client/src/v1.ts | 58 +++++++++-- docs/openapi/2027-01.openapi.json | 95 ++++++++++++++++--- server/polar/customer_meter/endpoints.py | 8 +- server/polar/customer_meter/schemas.py | 31 ++++-- server/polar/customer_meter/service.py | 32 +++++-- server/tests/customer_meter/test_endpoints.py | 48 ++++++++++ 6 files changed, 233 insertions(+), 39 deletions(-) diff --git a/clients/packages/client/src/v1.ts b/clients/packages/client/src/v1.ts index eaa8faadd3a..8570f5ae985 100644 --- a/clients/packages/client/src/v1.ts +++ b/clients/packages/client/src/v1.ts @@ -19205,17 +19205,47 @@ export interface components { detail: string } /** - * CustomerMeterCreditsCreate - * @description Schema to manually grant credits on a customer meter. + * CustomerMeterCreditsCustomerExternalIDCreate + * @description Schema to manually grant credits on a customer meter + * using an external customer ID. */ - CustomerMeterCreditsCreate: { + CustomerMeterCreditsCustomerExternalIDCreate: { /** - * Customer Id + * Meter Id * Format: uuid4 - * @description The ID of the customer. - * @example 992fae2a-2a17-4b7a-8d9e-e287cf90131b + * @description The ID of the meter. + * @example d498a884-e2cd-4d3e-8002-f536468a8b22 */ - customer_id: string + meter_id: string + /** + * Units + * @description The number of units to credit. + * @example 100 + */ + units: number + /** + * Rollover + * @description Whether unused credits carry over to the next meter period. + * @default false + */ + rollover: boolean + /** + * External Id + * @description Your unique identifier for this credit. Retrying with the same `external_id` doesn't grant the credit twice. + */ + external_id?: string | null + /** + * External Customer Id + * @description The external ID of the customer. + * @example usr_1337 + */ + external_customer_id: string + } + /** + * CustomerMeterCreditsCustomerIDCreate + * @description Schema to manually grant credits on a customer meter using a customer ID. + */ + CustomerMeterCreditsCustomerIDCreate: { /** * Meter Id * Format: uuid4 @@ -19240,6 +19270,13 @@ export interface components { * @description Your unique identifier for this credit. Retrying with the same `external_id` doesn't grant the credit twice. */ external_id?: string | null + /** + * Customer Id + * Format: uuid4 + * @description The ID of the customer. + * @example 992fae2a-2a17-4b7a-8d9e-e287cf90131b + */ + customer_id: string } /** * CustomerMeterSortProperty @@ -63205,7 +63242,9 @@ export interface operations { } requestBody: { content: { - 'application/json': components['schemas']['CustomerMeterCreditsCreate'] + 'application/json': + | components['schemas']['CustomerMeterCreditsCustomerIDCreate'] + | components['schemas']['CustomerMeterCreditsCustomerExternalIDCreate'] } } responses: { @@ -63229,7 +63268,7 @@ export interface operations { | components['schemas']['ConfigAsCodeNotEnabled'] } } - /** @description The external_id is already used by a different event, or by a concurrent request. */ + /** @description The external_id is already used by a different event, or by a concurrent request, or the external customer ID matches customers in several accessible organizations. */ 409: { headers: { [name: string]: unknown @@ -63238,6 +63277,7 @@ export interface operations { 'application/json': | components['schemas']['CustomerMeterCreditExternalIDConflict'] | components['schemas']['CustomerMeterCreditConcurrentRequest'] + | components['schemas']['AmbiguousExternalCustomerID'] } } /** @description Validation Error */ diff --git a/docs/openapi/2027-01.openapi.json b/docs/openapi/2027-01.openapi.json index eddd516bd9b..96d95c2a51a 100644 --- a/docs/openapi/2027-01.openapi.json +++ b/docs/openapi/2027-01.openapi.json @@ -25386,7 +25386,15 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CustomerMeterCreditsCreate" + "anyOf": [ + { + "$ref": "#/components/schemas/CustomerMeterCreditsCustomerIDCreate" + }, + { + "$ref": "#/components/schemas/CustomerMeterCreditsCustomerExternalIDCreate" + } + ], + "title": "Credit Create" } } }, @@ -25422,7 +25430,7 @@ } }, "409": { - "description": "The external_id is already used by a different event, or by a concurrent request.", + "description": "The external_id is already used by a different event, or by a concurrent request, or the external customer ID matches customers in several accessible organizations.", "content": { "application/json": { "schema": { @@ -25432,6 +25440,9 @@ }, { "$ref": "#/components/schemas/CustomerMeterCreditConcurrentRequest" + }, + { + "$ref": "#/components/schemas/AmbiguousExternalCustomerID" } ], "title": "Response 409 Customer Meters:Credits" @@ -25476,11 +25487,11 @@ "x-codeSamples": [ { "lang": "python", - "source": "from polar.v2027_01 import Polar\n\npolar = Polar(\"polar_oat_xxx\")\n\nresponse = polar.customer_meters.credits(\n customer_id='992fae2a-2a17-4b7a-8d9e-e287cf90131b',\n meter_id='d498a884-e2cd-4d3e-8002-f536468a8b22',\n units=100,\n rollover=False,\n)\nprint(response)\n" + "source": "from polar.v2027_01 import Polar\n\npolar = Polar(\"polar_oat_xxx\")\n\nresponse = polar.customer_meters.credits(\n meter_id='d498a884-e2cd-4d3e-8002-f536468a8b22',\n units=100,\n rollover=False,\n customer_id='992fae2a-2a17-4b7a-8d9e-e287cf90131b',\n)\nprint(response)\n" }, { "lang": "typescript", - "source": "import { createPolar } from \"@polar-sh/sdk/2027-01\";\n\nconst polar = createPolar({\n accessToken: \"polar_oat_xxx\",\n});\n\nconst response = await polar.customerMeters.credits(\n {\n \"customer_id\": \"992fae2a-2a17-4b7a-8d9e-e287cf90131b\",\n \"meter_id\": \"d498a884-e2cd-4d3e-8002-f536468a8b22\",\n \"units\": 100,\n \"rollover\": false\n },\n);\nconsole.log(response);\n" + "source": "import { createPolar } from \"@polar-sh/sdk/2027-01\";\n\nconst polar = createPolar({\n accessToken: \"polar_oat_xxx\",\n});\n\nconst response = await polar.customerMeters.credits(\n {\n \"meter_id\": \"d498a884-e2cd-4d3e-8002-f536468a8b22\",\n \"units\": 100,\n \"rollover\": false,\n \"customer_id\": \"992fae2a-2a17-4b7a-8d9e-e287cf90131b\"\n },\n);\nconsole.log(response);\n" } ] } @@ -49318,17 +49329,66 @@ ], "title": "CustomerMeterCreditExternalIDConflict" }, - "CustomerMeterCreditsCreate": { + "CustomerMeterCreditsCustomerExternalIDCreate": { "properties": { - "customer_id": { + "meter_id": { "type": "string", "format": "uuid4", - "title": "Customer Id", - "description": "The ID of the customer.", + "title": "Meter Id", + "description": "The ID of the meter.", "examples": [ - "992fae2a-2a17-4b7a-8d9e-e287cf90131b" + "d498a884-e2cd-4d3e-8002-f536468a8b22" ] }, + "units": { + "type": "integer", + "maximum": 1000000000.0, + "exclusiveMinimum": 0.0, + "title": "Units", + "description": "The number of units to credit.", + "examples": [ + 100 + ] + }, + "rollover": { + "type": "boolean", + "title": "Rollover", + "description": "Whether unused credits carry over to the next meter period.", + "default": false + }, + "external_id": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "External Id", + "description": "Your unique identifier for this credit. Retrying with the same `external_id` doesn't grant the credit twice." + }, + "external_customer_id": { + "type": "string", + "title": "External Customer Id", + "description": "The external ID of the customer.", + "examples": [ + "usr_1337" + ] + } + }, + "type": "object", + "required": [ + "meter_id", + "units", + "external_customer_id" + ], + "title": "CustomerMeterCreditsCustomerExternalIDCreate", + "description": "Schema to manually grant credits on a customer meter\nusing an external customer ID." + }, + "CustomerMeterCreditsCustomerIDCreate": { + "properties": { "meter_id": { "type": "string", "format": "uuid4", @@ -49366,16 +49426,25 @@ ], "title": "External Id", "description": "Your unique identifier for this credit. Retrying with the same `external_id` doesn't grant the credit twice." + }, + "customer_id": { + "type": "string", + "format": "uuid4", + "title": "Customer Id", + "description": "The ID of the customer.", + "examples": [ + "992fae2a-2a17-4b7a-8d9e-e287cf90131b" + ] } }, "type": "object", "required": [ - "customer_id", "meter_id", - "units" + "units", + "customer_id" ], - "title": "CustomerMeterCreditsCreate", - "description": "Schema to manually grant credits on a customer meter." + "title": "CustomerMeterCreditsCustomerIDCreate", + "description": "Schema to manually grant credits on a customer meter using a customer ID." }, "CustomerMeterSortProperty": { "type": "string", diff --git a/server/polar/customer_meter/endpoints.py b/server/polar/customer_meter/endpoints.py index 52bedf69fdb..7afc35e460b 100644 --- a/server/polar/customer_meter/endpoints.py +++ b/server/polar/customer_meter/endpoints.py @@ -1,6 +1,7 @@ from fastapi import Depends, Query from polar.customer.schemas.customer import CustomerID +from polar.customer_session.service import AmbiguousExternalCustomerID from polar.declarative_config.exceptions import ConfigAsCodeNotEnabled from polar.exceptions import NotPermitted, ResourceNotFound from polar.kit.pagination import ListResource, PaginationParamsQuery @@ -168,10 +169,13 @@ async def get( 409: { "description": ( "The external_id is already used by a different event, " - "or by a concurrent request." + "or by a concurrent request, " + "or the external customer ID matches customers " + "in several accessible organizations." ), "model": CustomerMeterCreditExternalIDConflict.schema() - | CustomerMeterCreditConcurrentRequest.schema(), + | CustomerMeterCreditConcurrentRequest.schema() + | AmbiguousExternalCustomerID.schema(), }, }, ) diff --git a/server/polar/customer_meter/schemas.py b/server/polar/customer_meter/schemas.py index 0ad0f9d3c34..cd07586a333 100644 --- a/server/polar/customer_meter/schemas.py +++ b/server/polar/customer_meter/schemas.py @@ -45,12 +45,7 @@ class CustomerMeter(CustomerMeterBase): meter: Meter = Field(description="The meter associated with this customer.") -class CustomerMeterCreditsCreate(Schema): - """Schema to manually grant credits on a customer meter.""" - - customer_id: UUID4 = Field( - description="The ID of the customer.", examples=[CUSTOMER_ID_EXAMPLE] - ) +class CustomerMeterCreditsCreateBase(Schema): meter_id: UUID4 = Field( description="The ID of the meter.", examples=[METER_ID_EXAMPLE] ) @@ -72,3 +67,27 @@ class CustomerMeterCreditsCreate(Schema): "Retrying with the same `external_id` doesn't grant the credit twice." ), ) + + +class CustomerMeterCreditsCustomerIDCreate(CustomerMeterCreditsCreateBase): + """Schema to manually grant credits on a customer meter using a customer ID.""" + + customer_id: UUID4 = Field( + description="The ID of the customer.", examples=[CUSTOMER_ID_EXAMPLE] + ) + + +class CustomerMeterCreditsCustomerExternalIDCreate(CustomerMeterCreditsCreateBase): + """ + Schema to manually grant credits on a customer meter + using an external customer ID. + """ + + external_customer_id: str = Field( + description="The external ID of the customer.", examples=["usr_1337"] + ) + + +CustomerMeterCreditsCreate = ( + CustomerMeterCreditsCustomerIDCreate | CustomerMeterCreditsCustomerExternalIDCreate +) diff --git a/server/polar/customer_meter/service.py b/server/polar/customer_meter/service.py index 514effcdab1..02f2c2ee0a0 100644 --- a/server/polar/customer_meter/service.py +++ b/server/polar/customer_meter/service.py @@ -6,7 +6,7 @@ import logfire from sqlalchemy import Float, Select, func, or_, select, union_all -from sqlalchemy.exc import DBAPIError, IntegrityError +from sqlalchemy.exc import DBAPIError, IntegrityError, MultipleResultsFound from sqlalchemy.orm import joinedload from sqlalchemy.orm.strategy_options import contains_eager @@ -15,6 +15,7 @@ from polar.authz.service import assert_organization_permission, get_accessible_org_ids from polar.config import settings from polar.customer.repository import CustomerRepository +from polar.customer_session.service import AmbiguousExternalCustomerID from polar.declarative_config.exceptions import ConfigAsCodeNotEnabled from polar.event.repository import EventRepository from polar.event.service import event as event_service @@ -42,7 +43,7 @@ from polar.worker import enqueue_job from .repository import CustomerMeterRepository -from .schemas import CustomerMeterCreditsCreate +from .schemas import CustomerMeterCreditsCreate, CustomerMeterCreditsCustomerIDCreate from .sorting import CustomerMeterSortProperty @@ -238,19 +239,32 @@ async def credit( org_ids = await get_accessible_org_ids(session, auth_subject) customer_repository = CustomerRepository.from_session(session) - customer = await customer_repository.get_readable_by_id( - org_ids, - credit_create.customer_id, - options=(joinedload(Customer.organization),), - ) + options = (joinedload(Customer.organization),) + if isinstance(credit_create, CustomerMeterCreditsCustomerIDCreate): + customer_field = "customer_id" + customer_value: uuid.UUID | str = credit_create.customer_id + customer = await customer_repository.get_readable_by_id( + org_ids, credit_create.customer_id, options=options + ) + else: + customer_field = "external_customer_id" + customer_value = credit_create.external_customer_id + try: + customer = await customer_repository.get_readable_by_external_id( + org_ids, credit_create.external_customer_id, options=options + ) + except MultipleResultsFound as e: + raise AmbiguousExternalCustomerID( + credit_create.external_customer_id + ) from e if customer is None: raise PolarRequestValidationError( [ { "type": "value_error", - "loc": ("body", "customer_id"), + "loc": ("body", customer_field), "msg": "Customer not found", - "input": credit_create.customer_id, + "input": customer_value, } ] ) diff --git a/server/tests/customer_meter/test_endpoints.py b/server/tests/customer_meter/test_endpoints.py index de12cfb8531..1c3a5d52c6d 100644 --- a/server/tests/customer_meter/test_endpoints.py +++ b/server/tests/customer_meter/test_endpoints.py @@ -173,6 +173,26 @@ async def test_customer_not_found( assert response.status_code == 422 assert response.json()["detail"][0]["loc"] == ["body", "customer_id"] + @pytest.mark.api_version(V2027_01) + @pytest.mark.auth + async def test_external_customer_not_found( + self, + client: AsyncClient, + user_organization: UserOrganization, + meter: Meter, + ) -> None: + response = await client.post( + "/v1/customer-meters/credits", + json={ + "external_customer_id": "unknown", + "meter_id": str(meter.id), + "units": 100, + }, + ) + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["body", "external_customer_id"] + @pytest.mark.api_version(V2027_01) @pytest.mark.auth async def test_meter_not_found( @@ -300,6 +320,34 @@ async def test_valid( assert json["customer"]["id"] == str(customer.id) assert json["meter"]["id"] == str(meter.id) + @pytest.mark.api_version(V2027_01) + @pytest.mark.auth + @pytest.mark.usefixtures("config_as_code_enabled") + async def test_valid_external_customer_id( + self, + save_fixture: SaveFixture, + client: AsyncClient, + user_organization: UserOrganization, + customer: Customer, + meter: Meter, + ) -> None: + customer.external_id = "usr_1337" + await save_fixture(customer) + + response = await client.post( + "/v1/customer-meters/credits", + json={ + "external_customer_id": "usr_1337", + "meter_id": str(meter.id), + "units": 100, + }, + ) + + assert response.status_code == 201 + json = response.json() + assert json["customer_id"] == str(customer.id) + assert json["balance"] == 100.0 + @pytest.mark.api_version(V2027_01) @pytest.mark.auth @pytest.mark.usefixtures("config_as_code_enabled") From aaf47e9cf94ecfb89d2e64a0894ef5130901e60b Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 14:49:34 +0200 Subject: [PATCH 06/29] server: Reject credit requests with both customer_id and external_customer_id --- server/polar/customer_meter/schemas.py | 17 +++++++++++++-- server/tests/customer_meter/test_endpoints.py | 21 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/server/polar/customer_meter/schemas.py b/server/polar/customer_meter/schemas.py index cd07586a333..4c8530715de 100644 --- a/server/polar/customer_meter/schemas.py +++ b/server/polar/customer_meter/schemas.py @@ -1,7 +1,7 @@ -from typing import Annotated +from typing import Annotated, Any from fastapi import Path -from pydantic import UUID4, Field +from pydantic import UUID4, Field, model_validator from polar.customer.schemas.customer import CustomerResponse as Customer from polar.kit.schemas import ( @@ -68,6 +68,19 @@ class CustomerMeterCreditsCreateBase(Schema): ), ) + @model_validator(mode="before") + @classmethod + def _single_customer_selector(cls, data: Any) -> Any: + if ( + isinstance(data, dict) + and "customer_id" in data + and "external_customer_id" in data + ): + raise ValueError( + "Provide either customer_id or external_customer_id, not both." + ) + return data + class CustomerMeterCreditsCustomerIDCreate(CustomerMeterCreditsCreateBase): """Schema to manually grant credits on a customer meter using a customer ID.""" diff --git a/server/tests/customer_meter/test_endpoints.py b/server/tests/customer_meter/test_endpoints.py index 1c3a5d52c6d..d97eae2a838 100644 --- a/server/tests/customer_meter/test_endpoints.py +++ b/server/tests/customer_meter/test_endpoints.py @@ -193,6 +193,27 @@ async def test_external_customer_not_found( assert response.status_code == 422 assert response.json()["detail"][0]["loc"] == ["body", "external_customer_id"] + @pytest.mark.api_version(V2027_01) + @pytest.mark.auth + async def test_both_customer_selectors( + self, + client: AsyncClient, + user_organization: UserOrganization, + customer: Customer, + meter: Meter, + ) -> None: + response = await client.post( + "/v1/customer-meters/credits", + json={ + "customer_id": str(customer.id), + "external_customer_id": "usr_1337", + "meter_id": str(meter.id), + "units": 100, + }, + ) + + assert response.status_code == 422 + @pytest.mark.api_version(V2027_01) @pytest.mark.auth async def test_meter_not_found( From 4a4a8b0d50030dd97db723b858366c19199be263 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 16:09:28 +0200 Subject: [PATCH 07/29] server: Return the credit event, dedupe on conflict, drop config-as-code gate - The credit customer meter endpoint returns the meter.credited event; the balance updates asynchronously. - external_id idempotency is checked after a unique index conflict instead of before the insert, so concurrent replays return the original event rather than a 409. - Crediting no longer requires config as code. --- clients/packages/client/src/v1.ts | 27 ++---- docs/openapi/2027-01.openapi.json | 67 ++------------- server/polar/customer_meter/endpoints.py | 35 ++++---- server/polar/customer_meter/service.py | 67 ++++++--------- server/tests/customer_meter/test_endpoints.py | 84 ++++++++++--------- 5 files changed, 104 insertions(+), 176 deletions(-) diff --git a/clients/packages/client/src/v1.ts b/clients/packages/client/src/v1.ts index 8570f5ae985..e8e4eb1b6a8 100644 --- a/clients/packages/client/src/v1.ts +++ b/clients/packages/client/src/v1.ts @@ -6219,6 +6219,9 @@ export interface paths { * Credit Customer Meter * @description Manually grant credits on a customer meter. * + * Returns the `meter.credited` event. The customer meter balance is updated + * asynchronously, shortly after the request. + * * **Scopes**: `customers:write` */ post: operations['customer_meters:credits'] @@ -19182,17 +19185,6 @@ export interface components { /** @description The meter associated with this customer. */ meter: components['schemas']['Meter'] } - /** CustomerMeterCreditConcurrentRequest */ - CustomerMeterCreditConcurrentRequest: { - /** - * Error - * @example CustomerMeterCreditConcurrentRequest - * @constant - */ - error: 'CustomerMeterCreditConcurrentRequest' - /** Detail */ - detail: string - } /** CustomerMeterCreditExternalIDConflict */ CustomerMeterCreditExternalIDConflict: { /** @@ -63248,27 +63240,25 @@ export interface operations { } } responses: { - /** @description Credits granted. */ + /** @description Credit recorded. The customer meter balance is updated asynchronously. */ 201: { headers: { [name: string]: unknown } content: { - 'application/json': components['schemas']['CustomerMeter'] + 'application/json': components['schemas']['MeterCreditEvent'] } } - /** @description You don't have permission to manage customers, or config as code is not enabled for this organization. */ + /** @description You don't have permission to manage customers. */ 403: { headers: { [name: string]: unknown } content: { - 'application/json': - | components['schemas']['NotPermitted'] - | components['schemas']['ConfigAsCodeNotEnabled'] + 'application/json': components['schemas']['NotPermitted'] } } - /** @description The external_id is already used by a different event, or by a concurrent request, or the external customer ID matches customers in several accessible organizations. */ + /** @description The external_id is already used by a different event, or the external customer ID matches customers in several accessible organizations. */ 409: { headers: { [name: string]: unknown @@ -63276,7 +63266,6 @@ export interface operations { content: { 'application/json': | components['schemas']['CustomerMeterCreditExternalIDConflict'] - | components['schemas']['CustomerMeterCreditConcurrentRequest'] | components['schemas']['AmbiguousExternalCustomerID'] } } diff --git a/docs/openapi/2027-01.openapi.json b/docs/openapi/2027-01.openapi.json index 96d95c2a51a..0205ee817ba 100644 --- a/docs/openapi/2027-01.openapi.json +++ b/docs/openapi/2027-01.openapi.json @@ -25380,7 +25380,7 @@ "cli" ], "summary": "Credit Customer Meter", - "description": "Manually grant credits on a customer meter.\n\n**Scopes**: `customers:write`", + "description": "Manually grant credits on a customer meter.\n\nReturns the `meter.credited` event. The customer meter balance is updated\nasynchronously, shortly after the request.\n\n**Scopes**: `customers:write`", "operationId": "customer_meters:credits", "requestBody": { "content": { @@ -25402,35 +25402,27 @@ }, "responses": { "201": { - "description": "Credits granted.", + "description": "Credit recorded. The customer meter balance is updated asynchronously.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CustomerMeter" + "$ref": "#/components/schemas/MeterCreditEvent" } } } }, "403": { - "description": "You don't have permission to manage customers, or config as code is not enabled for this organization.", + "description": "You don't have permission to manage customers.", "content": { "application/json": { "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/NotPermitted" - }, - { - "$ref": "#/components/schemas/ConfigAsCodeNotEnabled" - } - ], - "title": "Response 403 Customer Meters:Credits" + "$ref": "#/components/schemas/NotPermitted" } } } }, "409": { - "description": "The external_id is already used by a different event, or by a concurrent request, or the external customer ID matches customers in several accessible organizations.", + "description": "The external_id is already used by a different event, or the external customer ID matches customers in several accessible organizations.", "content": { "application/json": { "schema": { @@ -25438,9 +25430,6 @@ { "$ref": "#/components/schemas/CustomerMeterCreditExternalIDConflict" }, - { - "$ref": "#/components/schemas/CustomerMeterCreditConcurrentRequest" - }, { "$ref": "#/components/schemas/AmbiguousExternalCustomerID" } @@ -44496,28 +44485,6 @@ "title": "CheckoutUpdatePublic", "description": "Update an existing checkout session using the client secret." }, - "ConfigAsCodeNotEnabled": { - "properties": { - "error": { - "type": "string", - "const": "ConfigAsCodeNotEnabled", - "title": "Error", - "examples": [ - "ConfigAsCodeNotEnabled" - ] - }, - "detail": { - "type": "string", - "title": "Detail" - } - }, - "type": "object", - "required": [ - "error", - "detail" - ], - "title": "ConfigAsCodeNotEnabled" - }, "CostMetadata-Input": { "properties": { "amount": { @@ -49285,28 +49252,6 @@ "title": "CustomerMeter", "description": "An active customer meter, with current consumed and credited units." }, - "CustomerMeterCreditConcurrentRequest": { - "properties": { - "error": { - "type": "string", - "const": "CustomerMeterCreditConcurrentRequest", - "title": "Error", - "examples": [ - "CustomerMeterCreditConcurrentRequest" - ] - }, - "detail": { - "type": "string", - "title": "Detail" - } - }, - "type": "object", - "required": [ - "error", - "detail" - ], - "title": "CustomerMeterCreditConcurrentRequest" - }, "CustomerMeterCreditExternalIDConflict": { "properties": { "error": { diff --git a/server/polar/customer_meter/endpoints.py b/server/polar/customer_meter/endpoints.py index 7afc35e460b..b545297bbe9 100644 --- a/server/polar/customer_meter/endpoints.py +++ b/server/polar/customer_meter/endpoints.py @@ -2,13 +2,13 @@ from polar.customer.schemas.customer import CustomerID from polar.customer_session.service import AmbiguousExternalCustomerID -from polar.declarative_config.exceptions import ConfigAsCodeNotEnabled +from polar.event.schemas import MeterCreditEvent from polar.exceptions import NotPermitted, ResourceNotFound from polar.kit.pagination import ListResource, PaginationParamsQuery from polar.kit.schemas import MultipleQueryFilter from polar.kit.versioning import version from polar.meter.schemas import MeterID -from polar.models import CustomerMeter +from polar.models import CustomerMeter, Event from polar.openapi import APITag from polar.organization.schemas import OrganizationID from polar.postgres import AsyncSession, get_db_session @@ -18,10 +18,7 @@ from . import auth, sorting from .schemas import CustomerMeter as CustomerMeterSchema from .schemas import CustomerMeterCreditsCreate, CustomerMeterID -from .service import ( - CustomerMeterCreditConcurrentRequest, - CustomerMeterCreditExternalIDConflict, -) +from .service import CustomerMeterCreditExternalIDConflict from .service import customer_meter as customer_meter_service router = APIRouter( @@ -155,26 +152,25 @@ async def get( @router.post( "/credits", summary="Credit Customer Meter", - response_model=CustomerMeterSchema, + response_model=MeterCreditEvent, status_code=201, responses={ - 201: {"description": "Credits granted."}, - 403: { + 201: { "description": ( - "You don't have permission to manage customers, " - "or config as code is not enabled for this organization." - ), - "model": NotPermitted.schema() | ConfigAsCodeNotEnabled.schema(), + "Credit recorded. The customer meter balance is updated asynchronously." + ) + }, + 403: { + "description": "You don't have permission to manage customers.", + "model": NotPermitted.schema(), }, 409: { "description": ( "The external_id is already used by a different event, " - "or by a concurrent request, " "or the external customer ID matches customers " "in several accessible organizations." ), "model": CustomerMeterCreditExternalIDConflict.schema() - | CustomerMeterCreditConcurrentRequest.schema() | AmbiguousExternalCustomerID.schema(), }, }, @@ -184,6 +180,11 @@ async def credits( credit_create: CustomerMeterCreditsCreate, auth_subject: auth.CustomerMeterWrite, session: AsyncSession = Depends(get_db_session), -) -> CustomerMeter: - """Manually grant credits on a customer meter.""" +) -> Event: + """ + Manually grant credits on a customer meter. + + Returns the `meter.credited` event. The customer meter balance is updated + asynchronously, shortly after the request. + """ return await customer_meter_service.credit(session, auth_subject, credit_create) diff --git a/server/polar/customer_meter/service.py b/server/polar/customer_meter/service.py index 02f2c2ee0a0..c223a8dcbf5 100644 --- a/server/polar/customer_meter/service.py +++ b/server/polar/customer_meter/service.py @@ -16,7 +16,6 @@ from polar.config import settings from polar.customer.repository import CustomerRepository from polar.customer_session.service import AmbiguousExternalCustomerID -from polar.declarative_config.exceptions import ConfigAsCodeNotEnabled from polar.event.repository import EventRepository from polar.event.service import event as event_service from polar.event.system import ( @@ -52,15 +51,6 @@ def __init__(self) -> None: super().__init__("This external_id is already used by a different event.", 409) -class CustomerMeterCreditConcurrentRequest(PolarError): - def __init__(self) -> None: - super().__init__( - "A credit with this external_id was created by a concurrent request. " - "Retry.", - 409, - ) - - class CustomerMeterService: async def list( self, @@ -235,7 +225,7 @@ async def credit( session: AsyncSession, auth_subject: AuthSubject[User | Organization], credit_create: CustomerMeterCreditsCreate, - ) -> CustomerMeter: + ) -> Event: org_ids = await get_accessible_org_ids(session, auth_subject) customer_repository = CustomerRepository.from_session(session) @@ -296,53 +286,50 @@ async def credit( customer.organization_id, OrganizationPermission.customers_manage, ) - if not customer.organization.is_config_as_code_enabled: - raise ConfigAsCodeNotEnabled() metadata: MeterCreditedMetadata = { "meter_id": str(meter.id), "units": credit_create.units, "rollover": credit_create.rollover, } - existing_event = None - if credit_create.external_id is not None: - event_repository = EventRepository.from_session(session) + event = build_system_event( + SystemEvent.meter_credited, + customer=customer, + organization=customer.organization, + metadata=metadata, + ) + event.external_id = credit_create.external_id + event_repository = EventRepository.from_session(session) + try: + async with session.begin_nested(): + await event_service.create_event(session, event) + except IntegrityError: + if credit_create.external_id is None: + raise existing_event = await event_repository.get_by_organization_and_external_id( customer.organization_id, credit_create.external_id ) - if existing_event is not None and ( + if existing_event is None: + raise + if ( existing_event.source != EventSource.system or existing_event.name != SystemEvent.meter_credited or existing_event.customer_id != customer.id or existing_event.user_metadata != metadata ): raise CustomerMeterCreditExternalIDConflict() + event = existing_event - if existing_event is None: - event = build_system_event( - SystemEvent.meter_credited, - customer=customer, - organization=customer.organization, - metadata=metadata, - ) - event.external_id = credit_create.external_id - try: - async with session.begin_nested(): - await event_service.create_event(session, event) - except IntegrityError as e: - if credit_create.external_id is None: - raise - raise CustomerMeterCreditConcurrentRequest() from e - - customer_meter, updated = await self.update_customer_meter( - session, customer, meter, activate_meter=True + customer_meter_repository = CustomerMeterRepository.from_session(session) + customer_meter = await customer_meter_repository.get_by_customer_and_meter( + customer.id, meter.id ) - assert customer_meter is not None - - if updated: - enqueue_job("customer.state_changed", customer.id) + if customer_meter is not None and customer_meter.activated_at is None: + await customer_meter_repository.update( + customer_meter, update_dict={"activated_at": utc_now()} + ) - loaded = await self.get(session, auth_subject, customer_meter.id) + loaded = await event_repository.get_by_id_with_eager(event.id) assert loaded is not None return loaded diff --git a/server/tests/customer_meter/test_endpoints.py b/server/tests/customer_meter/test_endpoints.py index d97eae2a838..a6ef9d4987b 100644 --- a/server/tests/customer_meter/test_endpoints.py +++ b/server/tests/customer_meter/test_endpoints.py @@ -4,6 +4,8 @@ import pytest from httpx import AsyncClient +from polar.customer_meter.repository import CustomerMeterRepository +from polar.customer_meter.service import customer_meter as customer_meter_service from polar.event.system import SystemEvent from polar.kit.utils import utc_now from polar.models import ( @@ -14,6 +16,7 @@ User, UserOrganization, ) +from polar.postgres import AsyncSession from polar.version import V2027_01 from tests.fixtures.auth import AuthSubjectFixture from tests.fixtures.database import SaveFixture @@ -42,17 +45,6 @@ async def customer_meter_organization_second( return customer_meter -@pytest.fixture -async def config_as_code_enabled( - save_fixture: SaveFixture, organization: Organization -) -> None: - organization.feature_settings = { - **organization.feature_settings, - "config_as_code_enabled": True, - } - await save_fixture(organization) - - @pytest.fixture async def customer_meters_with_external_ids( save_fixture: SaveFixture, organization: Organization, customer: Customer @@ -288,9 +280,13 @@ async def test_archived_meter( assert response.json()["detail"][0]["loc"] == ["body", "meter_id"] @pytest.mark.api_version(V2027_01) - @pytest.mark.auth - async def test_config_as_code_not_enabled( + @pytest.mark.auth( + AuthSubjectFixture(subject="user"), + AuthSubjectFixture(subject="organization"), + ) + async def test_valid( self, + session: AsyncSession, client: AsyncClient, user_organization: UserOrganization, customer: Customer, @@ -302,48 +298,65 @@ async def test_config_as_code_not_enabled( "customer_id": str(customer.id), "meter_id": str(meter.id), "units": 100, + "rollover": True, }, ) - assert response.status_code == 403 - assert response.json()["error"] == "ConfigAsCodeNotEnabled" + assert response.status_code == 201 + json = response.json() + assert json["source"] == "system" + assert json["name"] == SystemEvent.meter_credited + assert json["customer_id"] == str(customer.id) + assert json["customer"]["id"] == str(customer.id) + assert json["metadata"] == { + "meter_id": str(meter.id), + "units": 100, + "rollover": True, + } + + await customer_meter_service.update_customer(session, customer) + repository = CustomerMeterRepository.from_session(session) + customer_meter = await repository.get_by_customer_and_meter( + customer.id, meter.id + ) + assert customer_meter is not None + assert customer_meter.activated_at is not None + assert customer_meter.balance == 100 @pytest.mark.api_version(V2027_01) - @pytest.mark.auth( - AuthSubjectFixture(subject="user"), - AuthSubjectFixture(subject="organization"), - ) - @pytest.mark.usefixtures("config_as_code_enabled") - async def test_valid( + @pytest.mark.auth + async def test_activates_existing_customer_meter( self, + save_fixture: SaveFixture, + session: AsyncSession, client: AsyncClient, user_organization: UserOrganization, customer: Customer, meter: Meter, ) -> None: + customer_meter = CustomerMeter(customer=customer, meter=meter) + await save_fixture(customer_meter) + response = await client.post( "/v1/customer-meters/credits", json={ "customer_id": str(customer.id), "meter_id": str(meter.id), "units": 100, - "rollover": True, }, ) assert response.status_code == 201 - json = response.json() - assert json["customer_id"] == str(customer.id) - assert json["meter_id"] == str(meter.id) - assert json["credited_units"] == 100 - assert json["consumed_units"] == 0.0 - assert json["balance"] == 100.0 - assert json["customer"]["id"] == str(customer.id) - assert json["meter"]["id"] == str(meter.id) + + await customer_meter_service.update_customer(session, customer) + repository = CustomerMeterRepository.from_session(session) + updated = await repository.get_by_customer_and_meter(customer.id, meter.id) + assert updated is not None + assert updated.activated_at is not None + assert updated.balance == 100 @pytest.mark.api_version(V2027_01) @pytest.mark.auth - @pytest.mark.usefixtures("config_as_code_enabled") async def test_valid_external_customer_id( self, save_fixture: SaveFixture, @@ -365,13 +378,10 @@ async def test_valid_external_customer_id( ) assert response.status_code == 201 - json = response.json() - assert json["customer_id"] == str(customer.id) - assert json["balance"] == 100.0 + assert response.json()["customer_id"] == str(customer.id) @pytest.mark.api_version(V2027_01) @pytest.mark.auth - @pytest.mark.usefixtures("config_as_code_enabled") async def test_external_id_replay( self, client: AsyncClient, @@ -392,12 +402,9 @@ async def test_external_id_replay( assert first.status_code == 201 assert second.status_code == 201 assert second.json()["id"] == first.json()["id"] - assert second.json()["credited_units"] == 100 - assert second.json()["balance"] == 100.0 @pytest.mark.api_version(V2027_01) @pytest.mark.auth - @pytest.mark.usefixtures("config_as_code_enabled") async def test_external_id_conflict( self, client: AsyncClient, @@ -423,7 +430,6 @@ async def test_external_id_conflict( @pytest.mark.api_version(V2027_01) @pytest.mark.auth - @pytest.mark.usefixtures("config_as_code_enabled") async def test_external_id_used_by_user_event( self, save_fixture: SaveFixture, From 68b8a4ec096a1c5a6472b53bffb33a9d4dd872e5 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 17:01:30 +0200 Subject: [PATCH 08/29] server: Move ConfigAsCodeNotEnabled back into the declarative config service The credit endpoint no longer imports it, so the separate exceptions module isn't needed. --- server/polar/declarative_config/endpoints.py | 2 +- server/polar/declarative_config/exceptions.py | 6 ------ server/polar/declarative_config/service.py | 6 +++++- server/tests/declarative_config/test_service.py | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) delete mode 100644 server/polar/declarative_config/exceptions.py diff --git a/server/polar/declarative_config/endpoints.py b/server/polar/declarative_config/endpoints.py index 17504ca2f5a..5a38e0df556 100644 --- a/server/polar/declarative_config/endpoints.py +++ b/server/polar/declarative_config/endpoints.py @@ -13,9 +13,9 @@ from polar.redis import Redis, get_redis from polar.routing import APIRouter -from .exceptions import ConfigAsCodeNotEnabled from .schemas import Config, ConfigApplyResult, ConfigExport, ConfigPlan from .service import ( + ConfigAsCodeNotEnabled, ConfigBenefitConflict, ConfigInvalid, ConfigMeterConflict, diff --git a/server/polar/declarative_config/exceptions.py b/server/polar/declarative_config/exceptions.py deleted file mode 100644 index f1148e5b9a1..00000000000 --- a/server/polar/declarative_config/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -from polar.exceptions import PolarError - - -class ConfigAsCodeNotEnabled(PolarError): - def __init__(self) -> None: - super().__init__("Config as code is not enabled for this organization.", 403) diff --git a/server/polar/declarative_config/service.py b/server/polar/declarative_config/service.py index 3d5f5924643..9248b24fd47 100644 --- a/server/polar/declarative_config/service.py +++ b/server/polar/declarative_config/service.py @@ -25,7 +25,6 @@ from polar.redis import Redis from . import benefits, discounts, products, validation -from .exceptions import ConfigAsCodeNotEnabled from .schemas import ( MAXIMUM_METERS, Config, @@ -84,6 +83,11 @@ class _OrganizationTarget: organization_id: UUID | None +class ConfigAsCodeNotEnabled(PolarError): + def __init__(self) -> None: + super().__init__("Config as code is not enabled for this organization.", 403) + + class ConfigMeterConflict(PolarError): def __init__(self) -> None: super().__init__( diff --git a/server/tests/declarative_config/test_service.py b/server/tests/declarative_config/test_service.py index 2b5a98b1138..64e11a5622b 100644 --- a/server/tests/declarative_config/test_service.py +++ b/server/tests/declarative_config/test_service.py @@ -7,7 +7,6 @@ from polar.auth.models import AuthSubject from polar.auth.scope import Scope from polar.benefit.repository import BenefitRepository -from polar.declarative_config.exceptions import ConfigAsCodeNotEnabled from polar.declarative_config.schemas import ( Config, ConfigAction, @@ -16,6 +15,7 @@ ConfigResource, ) from polar.declarative_config.service import ( + ConfigAsCodeNotEnabled, ConfigBenefitConflict, ConfigInvalid, ConfigMeterConflict, From df27ea6f4194afb54d82b5d9f827d62be517afe4 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 17:01:30 +0200 Subject: [PATCH 09/29] server: Accept external_meter_id when crediting customer meters Exactly one of meter_id or external_meter_id is required. The external ID is looked up in the customer's organization. Trim the credit endpoint tests to the ones that cover distinct behavior. Regenerate client and docs. --- clients/packages/client/src/v1.ts | 22 +++- docs/openapi/2027-01.openapi.json | 64 ++++++++-- server/polar/customer_meter/schemas.py | 25 +++- server/polar/customer_meter/service.py | 22 +++- server/tests/customer_meter/test_endpoints.py | 111 ++++-------------- 5 files changed, 134 insertions(+), 110 deletions(-) diff --git a/clients/packages/client/src/v1.ts b/clients/packages/client/src/v1.ts index e8e4eb1b6a8..1d08d00f8df 100644 --- a/clients/packages/client/src/v1.ts +++ b/clients/packages/client/src/v1.ts @@ -19204,11 +19204,16 @@ export interface components { CustomerMeterCreditsCustomerExternalIDCreate: { /** * Meter Id - * Format: uuid4 - * @description The ID of the meter. + * @description The ID of the meter. Required if `external_meter_id` is not provided. * @example d498a884-e2cd-4d3e-8002-f536468a8b22 */ - meter_id: string + meter_id?: string | null + /** + * External Meter Id + * @description The external ID of the meter, as set by config as code. Required if `meter_id` is not provided. + * @example tool_call + */ + external_meter_id?: string | null /** * Units * @description The number of units to credit. @@ -19240,11 +19245,16 @@ export interface components { CustomerMeterCreditsCustomerIDCreate: { /** * Meter Id - * Format: uuid4 - * @description The ID of the meter. + * @description The ID of the meter. Required if `external_meter_id` is not provided. * @example d498a884-e2cd-4d3e-8002-f536468a8b22 */ - meter_id: string + meter_id?: string | null + /** + * External Meter Id + * @description The external ID of the meter, as set by config as code. Required if `meter_id` is not provided. + * @example tool_call + */ + external_meter_id?: string | null /** * Units * @description The number of units to credit. diff --git a/docs/openapi/2027-01.openapi.json b/docs/openapi/2027-01.openapi.json index 0205ee817ba..32ff76f754b 100644 --- a/docs/openapi/2027-01.openapi.json +++ b/docs/openapi/2027-01.openapi.json @@ -25476,11 +25476,11 @@ "x-codeSamples": [ { "lang": "python", - "source": "from polar.v2027_01 import Polar\n\npolar = Polar(\"polar_oat_xxx\")\n\nresponse = polar.customer_meters.credits(\n meter_id='d498a884-e2cd-4d3e-8002-f536468a8b22',\n units=100,\n rollover=False,\n customer_id='992fae2a-2a17-4b7a-8d9e-e287cf90131b',\n)\nprint(response)\n" + "source": "from polar.v2027_01 import Polar\n\npolar = Polar(\"polar_oat_xxx\")\n\nresponse = polar.customer_meters.credits(\n meter_id='d498a884-e2cd-4d3e-8002-f536468a8b22',\n external_meter_id='tool_call',\n units=100,\n rollover=False,\n customer_id='992fae2a-2a17-4b7a-8d9e-e287cf90131b',\n)\nprint(response)\n" }, { "lang": "typescript", - "source": "import { createPolar } from \"@polar-sh/sdk/2027-01\";\n\nconst polar = createPolar({\n accessToken: \"polar_oat_xxx\",\n});\n\nconst response = await polar.customerMeters.credits(\n {\n \"meter_id\": \"d498a884-e2cd-4d3e-8002-f536468a8b22\",\n \"units\": 100,\n \"rollover\": false,\n \"customer_id\": \"992fae2a-2a17-4b7a-8d9e-e287cf90131b\"\n },\n);\nconsole.log(response);\n" + "source": "import { createPolar } from \"@polar-sh/sdk/2027-01\";\n\nconst polar = createPolar({\n accessToken: \"polar_oat_xxx\",\n});\n\nconst response = await polar.customerMeters.credits(\n {\n \"meter_id\": \"d498a884-e2cd-4d3e-8002-f536468a8b22\",\n \"external_meter_id\": \"tool_call\",\n \"units\": 100,\n \"rollover\": false,\n \"customer_id\": \"992fae2a-2a17-4b7a-8d9e-e287cf90131b\"\n },\n);\nconsole.log(response);\n" } ] } @@ -49277,14 +49277,37 @@ "CustomerMeterCreditsCustomerExternalIDCreate": { "properties": { "meter_id": { - "type": "string", - "format": "uuid4", + "anyOf": [ + { + "type": "string", + "format": "uuid4" + }, + { + "type": "null" + } + ], "title": "Meter Id", - "description": "The ID of the meter.", + "description": "The ID of the meter. Required if `external_meter_id` is not provided.", "examples": [ "d498a884-e2cd-4d3e-8002-f536468a8b22" ] }, + "external_meter_id": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "External Meter Id", + "description": "The external ID of the meter, as set by config as code. Required if `meter_id` is not provided.", + "examples": [ + "tool_call" + ] + }, "units": { "type": "integer", "maximum": 1000000000.0, @@ -49325,7 +49348,6 @@ }, "type": "object", "required": [ - "meter_id", "units", "external_customer_id" ], @@ -49335,14 +49357,37 @@ "CustomerMeterCreditsCustomerIDCreate": { "properties": { "meter_id": { - "type": "string", - "format": "uuid4", + "anyOf": [ + { + "type": "string", + "format": "uuid4" + }, + { + "type": "null" + } + ], "title": "Meter Id", - "description": "The ID of the meter.", + "description": "The ID of the meter. Required if `external_meter_id` is not provided.", "examples": [ "d498a884-e2cd-4d3e-8002-f536468a8b22" ] }, + "external_meter_id": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "External Meter Id", + "description": "The external ID of the meter, as set by config as code. Required if `meter_id` is not provided.", + "examples": [ + "tool_call" + ] + }, "units": { "type": "integer", "maximum": 1000000000.0, @@ -49384,7 +49429,6 @@ }, "type": "object", "required": [ - "meter_id", "units", "customer_id" ], diff --git a/server/polar/customer_meter/schemas.py b/server/polar/customer_meter/schemas.py index 4c8530715de..1b60c3a8c4d 100644 --- a/server/polar/customer_meter/schemas.py +++ b/server/polar/customer_meter/schemas.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any +from typing import Annotated, Any, Self from fastapi import Path from pydantic import UUID4, Field, model_validator @@ -46,8 +46,21 @@ class CustomerMeter(CustomerMeterBase): class CustomerMeterCreditsCreateBase(Schema): - meter_id: UUID4 = Field( - description="The ID of the meter.", examples=[METER_ID_EXAMPLE] + meter_id: UUID4 | None = Field( + default=None, + description=( + "The ID of the meter. Required if `external_meter_id` is not provided." + ), + examples=[METER_ID_EXAMPLE], + ) + external_meter_id: str | None = Field( + default=None, + min_length=1, + description=( + "The external ID of the meter, as set by config as code. " + "Required if `meter_id` is not provided." + ), + examples=["tool_call"], ) units: int = Field( gt=0, @@ -81,6 +94,12 @@ def _single_customer_selector(cls, data: Any) -> Any: ) return data + @model_validator(mode="after") + def _single_meter_selector(self) -> Self: + if (self.meter_id is None) == (self.external_meter_id is None): + raise ValueError("Provide either meter_id or external_meter_id.") + return self + class CustomerMeterCreditsCustomerIDCreate(CustomerMeterCreditsCreateBase): """Schema to manually grant credits on a customer meter using a customer ID.""" diff --git a/server/polar/customer_meter/service.py b/server/polar/customer_meter/service.py index c223a8dcbf5..500f2d21f77 100644 --- a/server/polar/customer_meter/service.py +++ b/server/polar/customer_meter/service.py @@ -260,9 +260,21 @@ async def credit( ) meter_repository = MeterRepository.from_session(session) - meter = await meter_repository.get_readable_by_id( - credit_create.meter_id, org_ids - ) + meter: Meter | None + if credit_create.meter_id is not None: + meter_field = "meter_id" + meter_value: uuid.UUID | str = credit_create.meter_id + meter = await meter_repository.get_readable_by_id( + credit_create.meter_id, org_ids + ) + else: + assert credit_create.external_meter_id is not None + meter_field = "external_meter_id" + meter_value = credit_create.external_meter_id + meters = await meter_repository.get_all_by_external_ids( + customer.organization_id, [credit_create.external_meter_id] + ) + meter = meters[0] if meters else None if ( meter is None @@ -273,9 +285,9 @@ async def credit( [ { "type": "value_error", - "loc": ("body", "meter_id"), + "loc": ("body", meter_field), "msg": "Meter not found", - "input": credit_create.meter_id, + "input": meter_value, } ] ) diff --git a/server/tests/customer_meter/test_endpoints.py b/server/tests/customer_meter/test_endpoints.py index a6ef9d4987b..bb4d62e3397 100644 --- a/server/tests/customer_meter/test_endpoints.py +++ b/server/tests/customer_meter/test_endpoints.py @@ -7,7 +7,6 @@ from polar.customer_meter.repository import CustomerMeterRepository from polar.customer_meter.service import customer_meter as customer_meter_service from polar.event.system import SystemEvent -from polar.kit.utils import utc_now from polar.models import ( Customer, CustomerMeter, @@ -145,46 +144,6 @@ async def test_anonymous_2027_01(self, client: AsyncClient) -> None: assert response.status_code == 401 - @pytest.mark.api_version(V2027_01) - @pytest.mark.auth - async def test_customer_not_found( - self, - client: AsyncClient, - user_organization: UserOrganization, - meter: Meter, - ) -> None: - response = await client.post( - "/v1/customer-meters/credits", - json={ - "customer_id": str(uuid.uuid4()), - "meter_id": str(meter.id), - "units": 100, - }, - ) - - assert response.status_code == 422 - assert response.json()["detail"][0]["loc"] == ["body", "customer_id"] - - @pytest.mark.api_version(V2027_01) - @pytest.mark.auth - async def test_external_customer_not_found( - self, - client: AsyncClient, - user_organization: UserOrganization, - meter: Meter, - ) -> None: - response = await client.post( - "/v1/customer-meters/credits", - json={ - "external_customer_id": "unknown", - "meter_id": str(meter.id), - "units": 100, - }, - ) - - assert response.status_code == 422 - assert response.json()["detail"][0]["loc"] == ["body", "external_customer_id"] - @pytest.mark.api_version(V2027_01) @pytest.mark.auth async def test_both_customer_selectors( @@ -206,26 +165,6 @@ async def test_both_customer_selectors( assert response.status_code == 422 - @pytest.mark.api_version(V2027_01) - @pytest.mark.auth - async def test_meter_not_found( - self, - client: AsyncClient, - user_organization: UserOrganization, - customer: Customer, - ) -> None: - response = await client.post( - "/v1/customer-meters/credits", - json={ - "customer_id": str(customer.id), - "meter_id": str(uuid.uuid4()), - "units": 100, - }, - ) - - assert response.status_code == 422 - assert response.json()["detail"][0]["loc"] == ["body", "meter_id"] - @pytest.mark.api_version(V2027_01) @pytest.mark.auth async def test_meter_from_other_accessible_organization( @@ -254,31 +193,6 @@ async def test_meter_from_other_accessible_organization( assert response.status_code == 422 assert response.json()["detail"][0]["loc"] == ["body", "meter_id"] - @pytest.mark.api_version(V2027_01) - @pytest.mark.auth - async def test_archived_meter( - self, - save_fixture: SaveFixture, - client: AsyncClient, - user_organization: UserOrganization, - customer: Customer, - meter: Meter, - ) -> None: - meter.archived_at = utc_now() - await save_fixture(meter) - - response = await client.post( - "/v1/customer-meters/credits", - json={ - "customer_id": str(customer.id), - "meter_id": str(meter.id), - "units": 100, - }, - ) - - assert response.status_code == 422 - assert response.json()["detail"][0]["loc"] == ["body", "meter_id"] - @pytest.mark.api_version(V2027_01) @pytest.mark.auth( AuthSubjectFixture(subject="user"), @@ -380,6 +294,31 @@ async def test_valid_external_customer_id( assert response.status_code == 201 assert response.json()["customer_id"] == str(customer.id) + @pytest.mark.api_version(V2027_01) + @pytest.mark.auth + async def test_valid_external_meter_id( + self, + client: AsyncClient, + user_organization: UserOrganization, + customer: Customer, + customer_meters_with_external_ids: tuple[CustomerMeter, CustomerMeter], + ) -> None: + tool_call_customer_meter, _ = customer_meters_with_external_ids + + response = await client.post( + "/v1/customer-meters/credits", + json={ + "customer_id": str(customer.id), + "external_meter_id": "tool_call", + "units": 100, + }, + ) + + assert response.status_code == 201 + assert response.json()["metadata"]["meter_id"] == str( + tool_call_customer_meter.meter_id + ) + @pytest.mark.api_version(V2027_01) @pytest.mark.auth async def test_external_id_replay( From 5c9eee50f5c06acbcc92cd80eed03d22bb64e55c Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 15:12:17 +0200 Subject: [PATCH 10/29] clients/web: Add manual credit granting to customer meters Adds an "Add credits" side panel that grants credits on a customer meter through POST /v1/customer-meters/credits. It opens from the customer-meter page with that meter preselected, and from the customer menu with no meter selected. The panel previews the new balance and, for billed meters, how much of the current overage the credits cover. Credits always roll over and can't be removed. The entry points only show for organizations with config as code enabled and members who can manage customers. --- .../meter/[meterId]/CustomerMeterPage.tsx | 6 + .../Customer/AddCredits/AddCreditsButton.tsx | 54 +++++ .../Customer/AddCredits/AddCreditsModal.tsx | 211 ++++++++++++++++++ .../Customer/AddCredits/AddCreditsSummary.tsx | 68 ++++++ .../Customer/AddCredits/creditPreview.test.ts | 101 +++++++++ .../Customer/AddCredits/creditPreview.ts | 57 +++++ .../Customer/AddCredits/useCanAddCredits.ts | 13 ++ .../AddCredits/useMeterCreditOptions.ts | 50 +++++ .../Customer/CustomerPage/CustomerHeader.tsx | 8 + .../web/src/hooks/queries/customerMeters.ts | 17 +- 10 files changed, 583 insertions(+), 2 deletions(-) create mode 100644 clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx create mode 100644 clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx create mode 100644 clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx create mode 100644 clients/apps/web/src/components/Customer/AddCredits/creditPreview.test.ts create mode 100644 clients/apps/web/src/components/Customer/AddCredits/creditPreview.ts create mode 100644 clients/apps/web/src/components/Customer/AddCredits/useCanAddCredits.ts create mode 100644 clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts diff --git a/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/[customerId]/meter/[meterId]/CustomerMeterPage.tsx b/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/[customerId]/meter/[meterId]/CustomerMeterPage.tsx index 1a2f3af229d..62e49303ccd 100644 --- a/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/[customerId]/meter/[meterId]/CustomerMeterPage.tsx +++ b/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/[customerId]/meter/[meterId]/CustomerMeterPage.tsx @@ -1,5 +1,6 @@ 'use client' +import { AddCreditsButton } from '@/components/Customer/AddCredits/AddCreditsButton' import { MasterDetailLayoutContent } from '@/components/Layout/MasterDetailLayout' import DateRangePicker, { DateRangeInterval, @@ -187,6 +188,11 @@ const CustomerMeterPage = ({
+ diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx new file mode 100644 index 00000000000..18a2fbf101d --- /dev/null +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useModal } from '@/components/Modal/useModal' +import { schemas } from '@polar-sh/client' +import { Button, InlineModal } from '@polar-sh/orbit' +import { AddCreditsModal } from './AddCreditsModal' +import { useCanAddCredits } from './useCanAddCredits' + +interface AddCreditsProps { + organization: schemas['Organization'] + customer: schemas['Customer'] + meterId?: string +} + +export const useAddCreditsModal = ({ + organization, + customer, + meterId, +}: AddCreditsProps) => { + const canAddCredits = useCanAddCredits(organization) + const { show, hide, isShown } = useModal() + + const modal = canAddCredits ? ( + + } + /> + ) : null + + return { canAddCredits, show, modal } +} + +export const AddCreditsButton = (props: AddCreditsProps) => { + const { canAddCredits, show, modal } = useAddCreditsModal(props) + + if (!canAddCredits) return null + + return ( + <> + + {modal} + + ) +} diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx new file mode 100644 index 00000000000..e68d4242759 --- /dev/null +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx @@ -0,0 +1,211 @@ +'use client' + +import { toast } from '@/components/Toast/use-toast' +import { useCreditCustomerMeter } from '@/hooks/queries/customerMeters' +import { extractApiErrorMessage, setValidationErrors } from '@/utils/api/errors' +import { isValidationError, schemas } from '@polar-sh/client' +import { + Button, + InlineModalHeader, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Text, +} from '@polar-sh/orbit' +import { Box } from '@polar-sh/orbit/Box' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@polar-sh/ui/components/ui/form' +import { useState } from 'react' +import { useForm, useWatch } from 'react-hook-form' +import { AddCreditsSummary } from './AddCreditsSummary' +import { getMeterUnitLabel, parseCreditUnits } from './creditPreview' +import { useMeterCreditOptions } from './useMeterCreditOptions' + +interface AddCreditsForm { + meter_id: string + units: string +} + +const BODY_SCHEMAS = [ + 'CustomerMeterCreditsCustomerIDCreate', + 'CustomerMeterCreditsCustomerExternalIDCreate', +] + +export const AddCreditsModal = ({ + organization, + customer, + meterId, + onClose, +}: { + organization: schemas['Organization'] + customer: schemas['Customer'] + meterId?: string + onClose: () => void +}) => { + const [externalId] = useState(() => crypto.randomUUID()) + const { options, isLoading } = useMeterCreditOptions( + organization.id, + customer, + ) + const creditCustomerMeter = useCreditCustomerMeter() + const form = useForm({ + defaultValues: { meter_id: meterId ?? '', units: '' }, + }) + + const values = useWatch({ control: form.control }) + const option = options.find((o) => o.meter.id === values.meter_id) + const units = parseCreditUnits(values.units ?? '') + const customerName = customer.name || customer.email || 'this customer' + + const onSubmit = async (values: AddCreditsForm) => { + const parsedUnits = parseCreditUnits(values.units) + if (!option || !parsedUnits) return + + const { error } = await creditCustomerMeter.mutateAsync({ + customer_id: customer.id, + meter_id: option.meter.id, + units: parsedUnits, + rollover: true, + external_id: externalId, + }) + + if (error) { + if (isValidationError(error.detail)) { + setValidationErrors(error.detail, form.setError, 1, BODY_SCHEMAS) + } else { + toast({ + title: 'Credits Not Added', + description: extractApiErrorMessage(error), + }) + } + return + } + + toast({ + title: 'Credits Added', + description: `Added ${parsedUnits.toLocaleString('en-US')} credits on ${option.meter.name} for ${customerName}.`, + }) + onClose() + } + + return ( + + + + Add credits + + +
+ + + + Give {customerName} free usage on a meter. Credits are used before + anything is billed. + + ( + + Meter + + + + )} + /> + + parseCreditUnits(value) !== null || + 'Enter a whole number between 1 and 1,000,000,000', + }} + render={({ field }) => ( + + Credits + + + {option ? getMeterUnitLabel(option.meter) : 'units'} + + } + /> + + + + )} + /> + {option && } + + + + Credits don't expire and can't be removed once added. + + + + + + + +
+
+ ) +} diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx new file mode 100644 index 00000000000..7e40c6a2789 --- /dev/null +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx @@ -0,0 +1,68 @@ +import { Text } from '@polar-sh/orbit' +import { Box } from '@polar-sh/orbit/Box' +import { formatCurrency } from '@polar-sh/currency' +import { getCreditPreview, MeterCreditOption } from './creditPreview' + +const formatUnits = (value: number) => + new Intl.NumberFormat('en-US', { maximumFractionDigits: 6 }).format(value) + +const SummaryRow = ({ + label, + value, + strong, +}: { + label: string + value: string + strong?: boolean +}) => ( + + {label} + {value} + +) + +export const AddCreditsSummary = ({ + option, + units, +}: { + option: MeterCreditOption + units: number +}) => { + const preview = getCreditPreview(option, units) + const formatMoney = (amount: number, currency: string) => + formatCurrency('compact')(amount, currency) + + return ( + + + + + + {preview.overage && ( + + )} + + + ) +} diff --git a/clients/apps/web/src/components/Customer/AddCredits/creditPreview.test.ts b/clients/apps/web/src/components/Customer/AddCredits/creditPreview.test.ts new file mode 100644 index 00000000000..3a1c6736bc3 --- /dev/null +++ b/clients/apps/web/src/components/Customer/AddCredits/creditPreview.test.ts @@ -0,0 +1,101 @@ +import { MeteredPrice } from '@/utils/product' +import { schemas } from '@polar-sh/client' +import { describe, expect, it } from 'vitest' +import { + getCreditPreview, + getMeterUnitLabel, + MeterCreditOption, + parseCreditUnits, +} from './creditPreview' + +const meter = (overrides: Partial = {}) => + ({ + id: 'meter_1', + name: 'AI credits', + unit: 'scalar', + custom_label: null, + custom_multiplier: null, + ...overrides, + }) as schemas['Meter'] + +const unitPrice = { + amount_type: 'metered_unit', + unit_amount: '1', + cap_amount: null, + price_currency: 'usd', + meter_id: 'meter_1', +} as unknown as MeteredPrice + +const option = ( + overrides: Partial = {}, +): MeterCreditOption => ({ + meter: meter(), + balance: 0, + unitPrice, + productName: 'Pro', + ...overrides, +}) + +describe('getCreditPreview', () => { + it('adds the credits to the current balance', () => { + expect(getCreditPreview(option({ balance: 120 }), 1000).balanceAfter).toBe( + 1120, + ) + }) + + it('shows how much of the current overage the credits cover', () => { + expect(getCreditPreview(option({ balance: -6250 }), 5000).overage).toEqual({ + before: 6250, + after: 1250, + currency: 'usd', + }) + }) + + it('drops the overage to zero once the balance is positive again', () => { + expect( + getCreditPreview(option({ balance: -6250 }), 7000).overage?.after, + ).toBe(0) + }) + + it('has no overage line when there is no overage to cover', () => { + expect(getCreditPreview(option({ balance: 300 }), 100).overage).toBeNull() + }) + + it('has no overage line when the meter is not billed', () => { + expect( + getCreditPreview(option({ balance: -500, unitPrice: null }), 100).overage, + ).toBeNull() + }) +}) + +describe('parseCreditUnits', () => { + it.each([ + ['1000', 1000], + ['1,000', 1000], + [' 25 000 ', 25000], + ])('parses %j', (raw, expected) => { + expect(parseCreditUnits(raw)).toBe(expected) + }) + + it.each(['', '0', '-5', '1.5', 'abc', '1000000001'])('rejects %j', (raw) => { + expect(parseCreditUnits(raw)).toBeNull() + }) +}) + +describe('getMeterUnitLabel', () => { + it('pluralizes the custom label', () => { + expect( + getMeterUnitLabel(meter({ unit: 'custom', custom_label: 'request' })), + ).toBe('requests') + }) + + it('keeps a custom label that is already plural', () => { + expect( + getMeterUnitLabel(meter({ unit: 'custom', custom_label: 'credits' })), + ).toBe('credits') + }) + + it('falls back to units for scalar meters', () => { + expect(getMeterUnitLabel(meter())).toBe('units') + }) +}) diff --git a/clients/apps/web/src/components/Customer/AddCredits/creditPreview.ts b/clients/apps/web/src/components/Customer/AddCredits/creditPreview.ts new file mode 100644 index 00000000000..c0eab5de187 --- /dev/null +++ b/clients/apps/web/src/components/Customer/AddCredits/creditPreview.ts @@ -0,0 +1,57 @@ +import { estimateMeteredCost } from '@/components/Products/ProductForm/Pricing/utils' +import { MeteredPrice } from '@/utils/product' +import { schemas } from '@polar-sh/client' + +export interface MeterCreditOption { + meter: schemas['Meter'] + balance: number + unitPrice: MeteredPrice | null + productName: string | null +} + +export interface CreditPreview { + balanceAfter: number + overage: { before: number; after: number; currency: string } | null +} + +const overageCost = (price: MeteredPrice, balance: number) => + balance < 0 ? estimateMeteredCost(price, -balance) : 0 + +export const getCreditPreview = ( + option: MeterCreditOption, + units: number, +): CreditPreview => { + const balanceAfter = option.balance + units + const before = option.unitPrice + ? overageCost(option.unitPrice, option.balance) + : 0 + if (!option.unitPrice || before === 0) { + return { balanceAfter, overage: null } + } + return { + balanceAfter, + overage: { + before, + after: overageCost(option.unitPrice, balanceAfter), + currency: option.unitPrice.price_currency, + }, + } +} + +export const parseCreditUnits = (raw: string): number | null => { + const units = Number(raw.replace(/[,\s]/g, '')) + if (!Number.isInteger(units) || units <= 0 || units > 1_000_000_000) { + return null + } + return units +} + +export const getMeterUnitLabel = (meter: schemas['Meter']): string => { + if (meter.unit === 'custom' && meter.custom_label) { + return meter.custom_label.endsWith('s') + ? meter.custom_label + : `${meter.custom_label}s` + } + if (meter.unit === 'token') return 'tokens' + return 'units' +} diff --git a/clients/apps/web/src/components/Customer/AddCredits/useCanAddCredits.ts b/clients/apps/web/src/components/Customer/AddCredits/useCanAddCredits.ts new file mode 100644 index 00000000000..a3bed6aab77 --- /dev/null +++ b/clients/apps/web/src/components/Customer/AddCredits/useCanAddCredits.ts @@ -0,0 +1,13 @@ +import { useHasPermission } from '@/hooks/permissions' +import { schemas } from '@polar-sh/client' + +export const useCanAddCredits = (organization: schemas['Organization']) => { + const canManageCustomers = useHasPermission( + organization.id, + 'customers:manage', + ) + return ( + canManageCustomers && + (organization.feature_settings?.config_as_code_enabled ?? false) + ) +} diff --git a/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts b/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts new file mode 100644 index 00000000000..a46ee8bc96b --- /dev/null +++ b/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts @@ -0,0 +1,50 @@ +import { useCustomerMeters } from '@/hooks/queries/customerMeters' +import { useMeters } from '@/hooks/queries/meters' +import { useSubscriptions } from '@/hooks/queries/subscriptions' +import { isMeteredPrice, MeteredPrice } from '@/utils/product' +import { schemas } from '@polar-sh/client' +import { useMemo } from 'react' +import { MeterCreditOption } from './creditPreview' + +export const useMeterCreditOptions = ( + organizationId: string, + customer: schemas['Customer'], +): { options: MeterCreditOption[]; isLoading: boolean } => { + const { data: meters, isLoading } = useMeters(organizationId, { + is_archived: false, + sorting: ['name'], + limit: 100, + }) + const { data: customerMeters } = useCustomerMeters(organizationId, { + customer_id: customer.id, + limit: 100, + }) + const { data: subscriptions } = useSubscriptions(organizationId, { + customer_id: customer.id, + active: true, + }) + + const options = useMemo( + () => + (meters?.items ?? []).map((meter): MeterCreditOption => { + const subscription = subscriptions?.items.find((s) => + s.meters.some((m) => m.meter_id === meter.id), + ) + return { + meter, + balance: + customerMeters?.items.find((cm) => cm.meter_id === meter.id) + ?.balance ?? 0, + unitPrice: + subscription?.product.prices.find( + (p): p is MeteredPrice => + isMeteredPrice(p) && p.meter_id === meter.id, + ) ?? null, + productName: subscription?.product.name ?? null, + } + }), + [meters, customerMeters, subscriptions], + ) + + return { options, isLoading } +} diff --git a/clients/apps/web/src/components/Customer/CustomerPage/CustomerHeader.tsx b/clients/apps/web/src/components/Customer/CustomerPage/CustomerHeader.tsx index 425303c546a..e407bc731e9 100644 --- a/clients/apps/web/src/components/Customer/CustomerPage/CustomerHeader.tsx +++ b/clients/apps/web/src/components/Customer/CustomerPage/CustomerHeader.tsx @@ -1,5 +1,6 @@ 'use client' +import { useAddCreditsModal } from '@/components/Customer/AddCredits/AddCreditsButton' import { EditCustomerModal } from '@/components/Customer/EditCustomerModal' import DateRangePicker from '@/components/Metrics/DateRangePicker' import IntervalPicker, { @@ -60,6 +61,7 @@ export const CustomerHeader = ({ hide: hideDeleteCustomerModal, isShown: isDeleteCustomerModalShown, } = useModal() + const addCredits = useAddCreditsModal({ organization, customer }) const safeCopy = useSafeCopy(toast) const canManageCustomers = useHasPermission( organization.id, @@ -192,6 +194,11 @@ export const CustomerHeader = ({ Edit Customer + {addCredits.canAddCredits && ( + + Add Credits + + )} Delete Customer @@ -208,6 +215,7 @@ export const CustomerHeader = ({ /> } /> + {addCredits.modal} + useMutation({ + mutationFn: (body: schemas['CustomerMeterCreditsCustomerIDCreate']) => + api.POST('/v1/customer-meters/credits', { body }), + onSuccess: async (result) => { + if (result.error) return + const queryClient = getQueryClient() + queryClient.invalidateQueries({ queryKey: ['customer_meters'] }) + queryClient.invalidateQueries({ queryKey: ['events'] }) + }, + }) From 44cf5a47e200c211078cfc552c82851dd28c77f3 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 15:15:44 +0200 Subject: [PATCH 11/29] clients/web: Add credits from customer usage table rows Each meter row on the customer Usage tab gets an "Add credits" button that opens the panel with that meter preselected. Column widths give the meter name more room so the extra column fits on narrower screens. --- .../Customer/AddCredits/AddCreditsButton.tsx | 36 +++++++++++++++++-- .../CustomerPage/CustomerUsageView.tsx | 14 ++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx index 18a2fbf101d..b173d72bc7b 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx @@ -2,7 +2,9 @@ import { useModal } from '@/components/Modal/useModal' import { schemas } from '@polar-sh/client' -import { Button, InlineModal } from '@polar-sh/orbit' +import { Button, DataTableColumnDef, InlineModal } from '@polar-sh/orbit' +import { Box } from '@polar-sh/orbit/Box' +import { useState } from 'react' import { AddCreditsModal } from './AddCreditsModal' import { useCanAddCredits } from './useCanAddCredits' @@ -19,6 +21,12 @@ export const useAddCreditsModal = ({ }: AddCreditsProps) => { const canAddCredits = useCanAddCredits(organization) const { show, hide, isShown } = useModal() + const [selectedMeterId, setSelectedMeterId] = useState(meterId) + + const showForMeter = (id: string) => { + setSelectedMeterId(id) + show() + } const modal = canAddCredits ? ( } /> ) : null - return { canAddCredits, show, modal } + return { canAddCredits, show, showForMeter, modal } } +export const addCreditsColumn = ( + onAddCredits: (meterId: string) => void, +): DataTableColumnDef => ({ + id: 'add_credits', + header: '', + size: 130, + cell: ({ row }) => ( + + + + ), +}) + export const AddCreditsButton = (props: AddCreditsProps) => { const { canAddCredits, show, modal } = useAddCreditsModal(props) diff --git a/clients/apps/web/src/components/Customer/CustomerPage/CustomerUsageView.tsx b/clients/apps/web/src/components/Customer/CustomerPage/CustomerUsageView.tsx index 77abb41f1fa..44c2fe0f522 100644 --- a/clients/apps/web/src/components/Customer/CustomerPage/CustomerUsageView.tsx +++ b/clients/apps/web/src/components/Customer/CustomerPage/CustomerUsageView.tsx @@ -1,5 +1,9 @@ 'use client' +import { + addCreditsColumn, + useAddCreditsModal, +} from '@/components/Customer/AddCredits/AddCreditsButton' import { estimateMeteredCost } from '@/components/Products/ProductForm/Pricing/utils' import { isMeteredPrice, MeteredPrice } from '@/utils/product' import { useCustomerMeters } from '@/hooks/queries/customerMeters' @@ -62,6 +66,7 @@ export const CustomerUsageView = ({ organization: schemas['Organization'] }) => { const router = useRouter() + const addCredits = useAddCreditsModal({ organization, customer }) const { dateRange, interval } = useCustomerMetricsParams(customer) const { data: customerMetersData, isLoading } = useCustomerMeters( customer.organization_id, @@ -175,6 +180,7 @@ export const CustomerUsageView = ({ ), accessorKey: 'meter.name', + size: 200, cell: ({ row }) => (
), accessorKey: 'consumed_units', + size: 110, cell: ({ row }) => ( ), @@ -204,6 +211,7 @@ export const CustomerUsageView = ({ ), accessorKey: 'credited_units', + size: 110, cell: ({ row }) => ( ), @@ -213,6 +221,7 @@ export const CustomerUsageView = ({ ), accessorKey: 'balance', + size: 110, cell: ({ row }) => { const { balance } = row.original return ( @@ -227,6 +236,7 @@ export const CustomerUsageView = ({ ), accessorKey: 'overages', + size: 110, cell: ({ row }) => { const { overages } = row.original return overages ? ( @@ -238,8 +248,12 @@ export const CustomerUsageView = ({ ) }, }, + ...(addCredits.canAddCredits + ? [addCreditsColumn(addCredits.showForMeter)] + : []), ]} /> + {addCredits.modal} ) } From f5c0882037535cc37d6198143534fbc9dfcef1bd Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 17:11:10 +0200 Subject: [PATCH 12/29] clients/web: Simplify the add credits panel - The panel checks the customers:manage permission itself and shows the restricted view, like the other customer modals; callers use useModal and InlineModal directly and only check the config as code flag. - The amount is a number input with form rules instead of a custom parser. - Reuse FormattedUnits and formatCurrency in the summary. - Drop the intro copy, the unit suffix and the schema-name discriminators. --- .../Customer/AddCredits/AddCreditsButton.tsx | 84 ++++------------ .../Customer/AddCredits/AddCreditsModal.tsx | 95 ++++++++++++------- .../Customer/AddCredits/AddCreditsSummary.tsx | 31 +++--- .../Customer/AddCredits/creditPreview.test.ts | 39 +------- .../Customer/AddCredits/creditPreview.ts | 18 ---- .../Customer/AddCredits/useCanAddCredits.ts | 13 --- .../Customer/CustomerPage/CustomerHeader.tsx | 24 ++++- .../CustomerPage/CustomerUsageView.tsx | 56 +++++++++-- 8 files changed, 166 insertions(+), 194 deletions(-) delete mode 100644 clients/apps/web/src/components/Customer/AddCredits/useCanAddCredits.ts diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx index b173d72bc7b..477b9411b7d 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx @@ -2,83 +2,39 @@ import { useModal } from '@/components/Modal/useModal' import { schemas } from '@polar-sh/client' -import { Button, DataTableColumnDef, InlineModal } from '@polar-sh/orbit' -import { Box } from '@polar-sh/orbit/Box' -import { useState } from 'react' +import { Button, InlineModal } from '@polar-sh/orbit' import { AddCreditsModal } from './AddCreditsModal' -import { useCanAddCredits } from './useCanAddCredits' -interface AddCreditsProps { - organization: schemas['Organization'] - customer: schemas['Customer'] - meterId?: string -} - -export const useAddCreditsModal = ({ +export const AddCreditsButton = ({ organization, customer, meterId, -}: AddCreditsProps) => { - const canAddCredits = useCanAddCredits(organization) +}: { + organization: schemas['Organization'] + customer: schemas['Customer'] + meterId: string +}) => { const { show, hide, isShown } = useModal() - const [selectedMeterId, setSelectedMeterId] = useState(meterId) - - const showForMeter = (id: string) => { - setSelectedMeterId(id) - show() - } - - const modal = canAddCredits ? ( - - } - /> - ) : null - - return { canAddCredits, show, showForMeter, modal } -} - -export const addCreditsColumn = ( - onAddCredits: (meterId: string) => void, -): DataTableColumnDef => ({ - id: 'add_credits', - header: '', - size: 130, - cell: ({ row }) => ( - - - - ), -}) - -export const AddCreditsButton = (props: AddCreditsProps) => { - const { canAddCredits, show, modal } = useAddCreditsModal(props) - if (!canAddCredits) return null + if (!organization.feature_settings?.config_as_code_enabled) return null return ( <> - {modal} + + } + /> ) } diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx index e68d4242759..d2eee492d7a 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx @@ -1,8 +1,11 @@ 'use client' +import AccessRestricted from '@/components/Finance/AccessRestricted' import { toast } from '@/components/Toast/use-toast' +import { useHasPermission } from '@/hooks/permissions' import { useCreditCustomerMeter } from '@/hooks/queries/customerMeters' import { extractApiErrorMessage, setValidationErrors } from '@/utils/api/errors' +import { permissionDeniedMessage } from '@/utils/permissions' import { isValidationError, schemas } from '@polar-sh/client' import { Button, @@ -27,19 +30,15 @@ import { import { useState } from 'react' import { useForm, useWatch } from 'react-hook-form' import { AddCreditsSummary } from './AddCreditsSummary' -import { getMeterUnitLabel, parseCreditUnits } from './creditPreview' import { useMeterCreditOptions } from './useMeterCreditOptions' +const MAX_UNITS = 1_000_000_000 + interface AddCreditsForm { meter_id: string - units: string + units?: number } -const BODY_SCHEMAS = [ - 'CustomerMeterCreditsCustomerIDCreate', - 'CustomerMeterCreditsCustomerExternalIDCreate', -] - export const AddCreditsModal = ({ organization, customer, @@ -51,6 +50,10 @@ export const AddCreditsModal = ({ meterId?: string onClose: () => void }) => { + const canManageCustomers = useHasPermission( + organization.id, + 'customers:manage', + ) const [externalId] = useState(() => crypto.randomUUID()) const { options, isLoading } = useMeterCreditOptions( organization.id, @@ -58,29 +61,32 @@ export const AddCreditsModal = ({ ) const creditCustomerMeter = useCreditCustomerMeter() const form = useForm({ - defaultValues: { meter_id: meterId ?? '', units: '' }, + defaultValues: { meter_id: meterId ?? '' }, }) const values = useWatch({ control: form.control }) const option = options.find((o) => o.meter.id === values.meter_id) - const units = parseCreditUnits(values.units ?? '') - const customerName = customer.name || customer.email || 'this customer' + const units = + values.units !== undefined && + Number.isInteger(values.units) && + values.units > 0 + ? values.units + : null - const onSubmit = async (values: AddCreditsForm) => { - const parsedUnits = parseCreditUnits(values.units) - if (!option || !parsedUnits) return + const onSubmit = async ({ units: submittedUnits }: AddCreditsForm) => { + if (!option || !submittedUnits) return const { error } = await creditCustomerMeter.mutateAsync({ customer_id: customer.id, meter_id: option.meter.id, - units: parsedUnits, + units: submittedUnits, rollover: true, external_id: externalId, }) if (error) { if (isValidationError(error.detail)) { - setValidationErrors(error.detail, form.setError, 1, BODY_SCHEMAS) + setValidationErrors(error.detail, form.setError) } else { toast({ title: 'Credits Not Added', @@ -92,18 +98,35 @@ export const AddCreditsModal = ({ toast({ title: 'Credits Added', - description: `Added ${parsedUnits.toLocaleString('en-US')} credits on ${option.meter.name} for ${customerName}.`, + description: `Added ${submittedUnits.toLocaleString('en-US')} credits on ${option.meter.name} for ${customer.name || customer.email || 'this customer'}.`, }) onClose() } + const header = ( + + + Add credits + + + ) + + if (!canManageCustomers) { + return ( + + {header} + + + + + ) + } + return ( - - - Add credits - - + {header}
- - Give {customerName} free usage on a meter. Credits are used before - anything is billed. - - parseCreditUnits(value) !== null || - 'Enter a whole number between 1 and 1,000,000,000', + Number.isInteger(value) || 'Enter a whole number', }} render={({ field }) => ( @@ -165,13 +189,16 @@ export const AddCreditsModal = ({ - {option ? getMeterUnitLabel(option.meter) : 'units'} - - } + type="number" + min={1} + max={MAX_UNITS} + step={1} + placeholder="1000" + value={field.value ?? ''} + onChange={(event) => { + const value = event.currentTarget.valueAsNumber + field.onChange(Number.isNaN(value) ? undefined : value) + }} /> diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx index 7e40c6a2789..ad7b6d2f14c 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx @@ -1,18 +1,17 @@ +import FormattedUnits from '@/components/Meter/FormattedUnits' import { Text } from '@polar-sh/orbit' import { Box } from '@polar-sh/orbit/Box' import { formatCurrency } from '@polar-sh/currency' +import { ReactNode } from 'react' import { getCreditPreview, MeterCreditOption } from './creditPreview' -const formatUnits = (value: number) => - new Intl.NumberFormat('en-US', { maximumFractionDigits: 6 }).format(value) - const SummaryRow = ({ label, value, strong, }: { label: string - value: string + value: ReactNode strong?: boolean }) => ( @@ -28,9 +27,7 @@ export const AddCreditsSummary = ({ option: MeterCreditOption units: number }) => { - const preview = getCreditPreview(option, units) - const formatMoney = (amount: number, currency: string) => - formatCurrency('compact')(amount, currency) + const { balanceAfter, overage } = getCreditPreview(option, units) return ( - - + } + /> + + + + + } + /> } /> - {preview.overage && ( + {overage && ( )} diff --git a/clients/apps/web/src/components/Customer/AddCredits/creditPreview.test.ts b/clients/apps/web/src/components/Customer/AddCredits/creditPreview.test.ts index 3a1c6736bc3..ea927a22837 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/creditPreview.test.ts +++ b/clients/apps/web/src/components/Customer/AddCredits/creditPreview.test.ts @@ -1,12 +1,7 @@ import { MeteredPrice } from '@/utils/product' import { schemas } from '@polar-sh/client' import { describe, expect, it } from 'vitest' -import { - getCreditPreview, - getMeterUnitLabel, - MeterCreditOption, - parseCreditUnits, -} from './creditPreview' +import { getCreditPreview, MeterCreditOption } from './creditPreview' const meter = (overrides: Partial = {}) => ({ @@ -67,35 +62,3 @@ describe('getCreditPreview', () => { ).toBeNull() }) }) - -describe('parseCreditUnits', () => { - it.each([ - ['1000', 1000], - ['1,000', 1000], - [' 25 000 ', 25000], - ])('parses %j', (raw, expected) => { - expect(parseCreditUnits(raw)).toBe(expected) - }) - - it.each(['', '0', '-5', '1.5', 'abc', '1000000001'])('rejects %j', (raw) => { - expect(parseCreditUnits(raw)).toBeNull() - }) -}) - -describe('getMeterUnitLabel', () => { - it('pluralizes the custom label', () => { - expect( - getMeterUnitLabel(meter({ unit: 'custom', custom_label: 'request' })), - ).toBe('requests') - }) - - it('keeps a custom label that is already plural', () => { - expect( - getMeterUnitLabel(meter({ unit: 'custom', custom_label: 'credits' })), - ).toBe('credits') - }) - - it('falls back to units for scalar meters', () => { - expect(getMeterUnitLabel(meter())).toBe('units') - }) -}) diff --git a/clients/apps/web/src/components/Customer/AddCredits/creditPreview.ts b/clients/apps/web/src/components/Customer/AddCredits/creditPreview.ts index c0eab5de187..b754bd30564 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/creditPreview.ts +++ b/clients/apps/web/src/components/Customer/AddCredits/creditPreview.ts @@ -37,21 +37,3 @@ export const getCreditPreview = ( }, } } - -export const parseCreditUnits = (raw: string): number | null => { - const units = Number(raw.replace(/[,\s]/g, '')) - if (!Number.isInteger(units) || units <= 0 || units > 1_000_000_000) { - return null - } - return units -} - -export const getMeterUnitLabel = (meter: schemas['Meter']): string => { - if (meter.unit === 'custom' && meter.custom_label) { - return meter.custom_label.endsWith('s') - ? meter.custom_label - : `${meter.custom_label}s` - } - if (meter.unit === 'token') return 'tokens' - return 'units' -} diff --git a/clients/apps/web/src/components/Customer/AddCredits/useCanAddCredits.ts b/clients/apps/web/src/components/Customer/AddCredits/useCanAddCredits.ts deleted file mode 100644 index a3bed6aab77..00000000000 --- a/clients/apps/web/src/components/Customer/AddCredits/useCanAddCredits.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useHasPermission } from '@/hooks/permissions' -import { schemas } from '@polar-sh/client' - -export const useCanAddCredits = (organization: schemas['Organization']) => { - const canManageCustomers = useHasPermission( - organization.id, - 'customers:manage', - ) - return ( - canManageCustomers && - (organization.feature_settings?.config_as_code_enabled ?? false) - ) -} diff --git a/clients/apps/web/src/components/Customer/CustomerPage/CustomerHeader.tsx b/clients/apps/web/src/components/Customer/CustomerPage/CustomerHeader.tsx index e407bc731e9..5e99efbbe73 100644 --- a/clients/apps/web/src/components/Customer/CustomerPage/CustomerHeader.tsx +++ b/clients/apps/web/src/components/Customer/CustomerPage/CustomerHeader.tsx @@ -1,6 +1,6 @@ 'use client' -import { useAddCreditsModal } from '@/components/Customer/AddCredits/AddCreditsButton' +import { AddCreditsModal } from '@/components/Customer/AddCredits/AddCreditsModal' import { EditCustomerModal } from '@/components/Customer/EditCustomerModal' import DateRangePicker from '@/components/Metrics/DateRangePicker' import IntervalPicker, { @@ -61,7 +61,11 @@ export const CustomerHeader = ({ hide: hideDeleteCustomerModal, isShown: isDeleteCustomerModalShown, } = useModal() - const addCredits = useAddCreditsModal({ organization, customer }) + const { + show: showAddCreditsModal, + hide: hideAddCreditsModal, + isShown: isAddCreditsModalShown, + } = useModal() const safeCopy = useSafeCopy(toast) const canManageCustomers = useHasPermission( organization.id, @@ -194,8 +198,8 @@ export const CustomerHeader = ({ Edit Customer - {addCredits.canAddCredits && ( - + {organization.feature_settings?.config_as_code_enabled && ( + Add Credits )} @@ -215,7 +219,17 @@ export const CustomerHeader = ({ /> } /> - {addCredits.modal} + + } + /> { const router = useRouter() - const addCredits = useAddCreditsModal({ organization, customer }) + const addCreditsModal = useModal() + const [creditMeterId, setCreditMeterId] = useState() const { dateRange, interval } = useCustomerMetricsParams(customer) const { data: customerMetersData, isLoading } = useCustomerMeters( customer.organization_id, @@ -248,12 +252,44 @@ export const CustomerUsageView = ({ ) }, }, - ...(addCredits.canAddCredits - ? [addCreditsColumn(addCredits.showForMeter)] + ...(organization.feature_settings?.config_as_code_enabled + ? [ + { + id: 'add_credits', + header: '', + size: 130, + cell: ({ row }: { row: { original: MeterRow } }) => ( + + + + ), + }, + ] : []), ]} /> - {addCredits.modal} + + } + /> ) } From b04dfb3438efee7cadc58d84d39e195f6943b65d Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 17:22:21 +0200 Subject: [PATCH 13/29] clients/web: Tidy the add credits panel - Explain an external_id conflict as a possibly already added grant instead of showing the raw API message. - Inline the panel header in both views, like the other customer modals. - Keep the overage cost helper local to getCreditPreview. - Build balance and subscription lookups by meter before mapping options. --- .../Customer/AddCredits/AddCreditsModal.tsx | 29 ++++++++---- .../Customer/AddCredits/creditPreview.ts | 20 ++++---- .../AddCredits/useMeterCreditOptions.ts | 47 ++++++++++--------- 3 files changed, 52 insertions(+), 44 deletions(-) diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx index d2eee492d7a..b2441ea6a89 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx @@ -87,6 +87,15 @@ export const AddCreditsModal = ({ if (error) { if (isValidationError(error.detail)) { setValidationErrors(error.detail, form.setError) + } else if ( + 'error' in error && + error.error === 'CustomerMeterCreditExternalIDConflict' + ) { + toast({ + title: 'Credits Not Added', + description: + 'These credits may already have been added. Refresh the page to check the balance.', + }) } else { toast({ title: 'Credits Not Added', @@ -103,18 +112,14 @@ export const AddCreditsModal = ({ onClose() } - const header = ( - - - Add credits - - - ) - if (!canManageCustomers) { return ( - {header} + + + Add credits + + - {header} + + + Add credits + + - balance < 0 ? estimateMeteredCost(price, -balance) : 0 - export const getCreditPreview = ( - option: MeterCreditOption, + { balance, unitPrice }: MeterCreditOption, units: number, ): CreditPreview => { - const balanceAfter = option.balance + units - const before = option.unitPrice - ? overageCost(option.unitPrice, option.balance) - : 0 - if (!option.unitPrice || before === 0) { + const balanceAfter = balance + units + if (!unitPrice || balance >= 0) { return { balanceAfter, overage: null } } + const overageCost = (value: number) => + value < 0 ? estimateMeteredCost(unitPrice, -value) : 0 return { balanceAfter, overage: { - before, - after: overageCost(option.unitPrice, balanceAfter), - currency: option.unitPrice.price_currency, + before: overageCost(balance), + after: overageCost(balanceAfter), + currency: unitPrice.price_currency, }, } } diff --git a/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts b/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts index a46ee8bc96b..0966558c21c 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts +++ b/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts @@ -9,7 +9,7 @@ import { MeterCreditOption } from './creditPreview' export const useMeterCreditOptions = ( organizationId: string, customer: schemas['Customer'], -): { options: MeterCreditOption[]; isLoading: boolean } => { +) => { const { data: meters, isLoading } = useMeters(organizationId, { is_archived: false, sorting: ['name'], @@ -24,27 +24,30 @@ export const useMeterCreditOptions = ( active: true, }) - const options = useMemo( - () => - (meters?.items ?? []).map((meter): MeterCreditOption => { - const subscription = subscriptions?.items.find((s) => - s.meters.some((m) => m.meter_id === meter.id), - ) - return { - meter, - balance: - customerMeters?.items.find((cm) => cm.meter_id === meter.id) - ?.balance ?? 0, - unitPrice: - subscription?.product.prices.find( - (p): p is MeteredPrice => - isMeteredPrice(p) && p.meter_id === meter.id, - ) ?? null, - productName: subscription?.product.name ?? null, - } - }), - [meters, customerMeters, subscriptions], - ) + const options = useMemo((): MeterCreditOption[] => { + const balanceByMeter = new Map( + customerMeters?.items.map((cm) => [cm.meter_id, cm.balance]), + ) + const subscriptionByMeter = new Map( + subscriptions?.items.flatMap((subscription) => + subscription.meters.map((m) => [m.meter_id, subscription] as const), + ), + ) + + return (meters?.items ?? []).map((meter) => { + const subscription = subscriptionByMeter.get(meter.id) + const unitPrice = subscription?.product.prices.find( + (price): price is MeteredPrice => + isMeteredPrice(price) && price.meter_id === meter.id, + ) + return { + meter, + balance: balanceByMeter.get(meter.id) ?? 0, + unitPrice: unitPrice ?? null, + productName: subscription?.product.name ?? null, + } + }) + }, [meters, customerMeters, subscriptions]) return { options, isLoading } } From ba169300e6ff00b4e0506fc263718a1528e33b78 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 17:26:30 +0200 Subject: [PATCH 14/29] clients/web: Name the duplicate credit check in the add credits panel --- .../Customer/AddCredits/AddCreditsModal.tsx | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx index b2441ea6a89..7bca5fd2eb4 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx @@ -87,21 +87,17 @@ export const AddCreditsModal = ({ if (error) { if (isValidationError(error.detail)) { setValidationErrors(error.detail, form.setError) - } else if ( + return + } + const alreadyAdded = 'error' in error && error.error === 'CustomerMeterCreditExternalIDConflict' - ) { - toast({ - title: 'Credits Not Added', - description: - 'These credits may already have been added. Refresh the page to check the balance.', - }) - } else { - toast({ - title: 'Credits Not Added', - description: extractApiErrorMessage(error), - }) - } + toast({ + title: 'Credits Not Added', + description: alreadyAdded + ? 'These credits may already have been added. Refresh the page to check the balance.' + : extractApiErrorMessage(error), + }) return } From ad814531573952f92c510bdfc067df576669865e Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 18:13:10 +0200 Subject: [PATCH 15/29] clients/web: Tighten the add credits panel UX Buttons follow the form instead of pinning to the drawer bottom, the amount is a numeric text field validated as you type, the credits field is focused when a meter is preselected, labels pluralize, and the entry points are hidden without customers:manage. --- .../Customer/AddCredits/AddCreditsButton.tsx | 11 +- .../Customer/AddCredits/AddCreditsModal.tsx | 198 +++++++----------- .../Customer/AddCredits/AddCreditsSummary.tsx | 42 ++-- .../Customer/AddCredits/creditPreview.test.ts | 44 +++- .../Customer/AddCredits/creditPreview.ts | 20 ++ .../AddCredits/useMeterCreditOptions.ts | 13 +- .../Customer/CustomerPage/CustomerHeader.tsx | 11 +- .../CustomerPage/CustomerUsageView.tsx | 6 +- 8 files changed, 195 insertions(+), 150 deletions(-) diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx index 477b9411b7d..09ba8890c9a 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx @@ -1,6 +1,7 @@ 'use client' import { useModal } from '@/components/Modal/useModal' +import { useHasPermission } from '@/hooks/permissions' import { schemas } from '@polar-sh/client' import { Button, InlineModal } from '@polar-sh/orbit' import { AddCreditsModal } from './AddCreditsModal' @@ -15,8 +16,16 @@ export const AddCreditsButton = ({ meterId: string }) => { const { show, hide, isShown } = useModal() + const canManageCustomers = useHasPermission( + organization.id, + 'customers:manage', + ) - if (!organization.feature_settings?.config_as_code_enabled) return null + if ( + !canManageCustomers || + !organization.feature_settings?.config_as_code_enabled + ) + return null return ( <> diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx index 7bca5fd2eb4..9da78d5d9f9 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx @@ -1,11 +1,8 @@ 'use client' -import AccessRestricted from '@/components/Finance/AccessRestricted' import { toast } from '@/components/Toast/use-toast' -import { useHasPermission } from '@/hooks/permissions' import { useCreditCustomerMeter } from '@/hooks/queries/customerMeters' import { extractApiErrorMessage, setValidationErrors } from '@/utils/api/errors' -import { permissionDeniedMessage } from '@/utils/permissions' import { isValidationError, schemas } from '@polar-sh/client' import { Button, @@ -30,13 +27,16 @@ import { import { useState } from 'react' import { useForm, useWatch } from 'react-hook-form' import { AddCreditsSummary } from './AddCreditsSummary' +import { + formatCredits, + parseCreditUnits, + validateCreditUnits, +} from './creditPreview' import { useMeterCreditOptions } from './useMeterCreditOptions' -const MAX_UNITS = 1_000_000_000 - interface AddCreditsForm { meter_id: string - units?: number + units: string } export const AddCreditsModal = ({ @@ -50,10 +50,6 @@ export const AddCreditsModal = ({ meterId?: string onClose: () => void }) => { - const canManageCustomers = useHasPermission( - organization.id, - 'customers:manage', - ) const [externalId] = useState(() => crypto.randomUUID()) const { options, isLoading } = useMeterCreditOptions( organization.id, @@ -61,19 +57,16 @@ export const AddCreditsModal = ({ ) const creditCustomerMeter = useCreditCustomerMeter() const form = useForm({ - defaultValues: { meter_id: meterId ?? '' }, + mode: 'onChange', + defaultValues: { meter_id: meterId ?? '', units: '' }, }) const values = useWatch({ control: form.control }) const option = options.find((o) => o.meter.id === values.meter_id) - const units = - values.units !== undefined && - Number.isInteger(values.units) && - values.units > 0 - ? values.units - : null + const units = parseCreditUnits(values.units ?? '') - const onSubmit = async ({ units: submittedUnits }: AddCreditsForm) => { + const onSubmit = async (submitted: AddCreditsForm) => { + const submittedUnits = parseCreditUnits(submitted.units) if (!option || !submittedUnits) return const { error } = await creditCustomerMeter.mutateAsync({ @@ -103,30 +96,13 @@ export const AddCreditsModal = ({ toast({ title: 'Credits Added', - description: `Added ${submittedUnits.toLocaleString('en-US')} credits on ${option.meter.name} for ${customer.name || customer.email || 'this customer'}.`, + description: `Added ${formatCredits(submittedUnits)} on ${option.meter.name} for ${customer.name || customer.email || 'this customer'}.`, }) onClose() } - if (!canManageCustomers) { - return ( - - - - Add credits - - - - - - - ) - } - return ( - + Add credits @@ -137,102 +113,80 @@ export const AddCreditsModal = ({ as="form" onSubmit={form.handleSubmit(onSubmit)} flexDirection="column" - flex={1} + rowGap="xl" + paddingHorizontal="2xl" + paddingBottom="3xl" > - - ( - - Meter - - - - )} - /> - - Number.isInteger(value) || 'Enter a whole number', - }} - render={({ field }) => ( - - Credits + ( + + Meter + { - const value = event.currentTarget.valueAsNumber - field.onChange(Number.isNaN(value) ? undefined : value) - }} - /> + + + - - - )} - /> - {option && } - - - + + {options.map(({ meter, productName }) => ( + + {meter.name} + {productName && ` · ${productName}`} + + ))} + + + + + )} + /> + ( + + Credits to add + + + + + + )} + /> + {(option || (isLoading && meterId)) && ( + + )} + + Credits don't expire and can't be removed once added. + - diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx index ad7b6d2f14c..aad534dc412 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx @@ -20,27 +20,41 @@ const SummaryRow = ({ ) +const SummaryCard = ({ children }: { children: ReactNode }) => ( + + {children} + +) + export const AddCreditsSummary = ({ option, units, }: { - option: MeterCreditOption - units: number + option?: MeterCreditOption + units: number | null }) => { + const currentBalance = ( + : '—'} + /> + ) + + if (!option || !units) { + return {currentBalance} + } + const { balanceAfter, overage } = getCreditPreview(option, units) return ( - - } - /> + + {currentBalance} )} - + ) } diff --git a/clients/apps/web/src/components/Customer/AddCredits/creditPreview.test.ts b/clients/apps/web/src/components/Customer/AddCredits/creditPreview.test.ts index ea927a22837..abdd9d3aba8 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/creditPreview.test.ts +++ b/clients/apps/web/src/components/Customer/AddCredits/creditPreview.test.ts @@ -1,7 +1,13 @@ import { MeteredPrice } from '@/utils/product' import { schemas } from '@polar-sh/client' import { describe, expect, it } from 'vitest' -import { getCreditPreview, MeterCreditOption } from './creditPreview' +import { + formatCredits, + getCreditPreview, + MeterCreditOption, + parseCreditUnits, + validateCreditUnits, +} from './creditPreview' const meter = (overrides: Partial = {}) => ({ @@ -62,3 +68,39 @@ describe('getCreditPreview', () => { ).toBeNull() }) }) + +describe('parseCreditUnits', () => { + it('accepts whole numbers with separators', () => { + expect(parseCreditUnits('1000')).toBe(1000) + expect(parseCreditUnits('1,000,000')).toBe(1_000_000) + expect(parseCreditUnits(' 2 500 ')).toBe(2500) + }) + + it('rejects anything that is not a whole number in range', () => { + expect(parseCreditUnits('')).toBeNull() + expect(parseCreditUnits('1.5')).toBeNull() + expect(parseCreditUnits('1e3')).toBeNull() + expect(parseCreditUnits('-5')).toBeNull() + expect(parseCreditUnits('0')).toBeNull() + expect(parseCreditUnits('1000000001')).toBeNull() + }) +}) + +describe('validateCreditUnits', () => { + it('explains why an amount is rejected', () => { + expect(validateCreditUnits('')).toBe('Enter an amount') + expect(validateCreditUnits('1.5')).toBe('Enter a whole number') + expect(validateCreditUnits('0')).toBe('Enter at least 1 credit') + expect(validateCreditUnits('2,000,000,000')).toBe( + 'Enter at most 1,000,000,000 credits', + ) + expect(validateCreditUnits('1,000')).toBe(true) + }) +}) + +describe('formatCredits', () => { + it('pluralizes', () => { + expect(formatCredits(1)).toBe('1 credit') + expect(formatCredits(1000)).toBe('1,000 credits') + }) +}) diff --git a/clients/apps/web/src/components/Customer/AddCredits/creditPreview.ts b/clients/apps/web/src/components/Customer/AddCredits/creditPreview.ts index 2034a949136..a18646fe266 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/creditPreview.ts +++ b/clients/apps/web/src/components/Customer/AddCredits/creditPreview.ts @@ -33,3 +33,23 @@ export const getCreditPreview = ( }, } } + +export const MAX_CREDIT_UNITS = 1_000_000_000 + +const stripSeparators = (input: string) => input.replace(/[\s,_]/g, '') + +export const validateCreditUnits = (input: string): true | string => { + const digits = stripSeparators(input) + if (!digits) return 'Enter an amount' + if (!/^\d+$/.test(digits)) return 'Enter a whole number' + const units = Number(digits) + if (units < 1) return 'Enter at least 1 credit' + if (units > MAX_CREDIT_UNITS) return 'Enter at most 1,000,000,000 credits' + return true +} + +export const parseCreditUnits = (input: string): number | null => + validateCreditUnits(input) === true ? Number(stripSeparators(input)) : null + +export const formatCredits = (units: number) => + `${units.toLocaleString('en-US')} ${units === 1 ? 'credit' : 'credits'}` diff --git a/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts b/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts index 0966558c21c..3cbdb8bb854 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts +++ b/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts @@ -10,15 +10,16 @@ export const useMeterCreditOptions = ( organizationId: string, customer: schemas['Customer'], ) => { - const { data: meters, isLoading } = useMeters(organizationId, { + const { data: meters, isLoading: metersLoading } = useMeters(organizationId, { is_archived: false, sorting: ['name'], limit: 100, }) - const { data: customerMeters } = useCustomerMeters(organizationId, { - customer_id: customer.id, - limit: 100, - }) + const { data: customerMeters, isLoading: customerMetersLoading } = + useCustomerMeters(organizationId, { + customer_id: customer.id, + limit: 100, + }) const { data: subscriptions } = useSubscriptions(organizationId, { customer_id: customer.id, active: true, @@ -49,5 +50,5 @@ export const useMeterCreditOptions = ( }) }, [meters, customerMeters, subscriptions]) - return { options, isLoading } + return { options, isLoading: metersLoading || customerMetersLoading } } diff --git a/clients/apps/web/src/components/Customer/CustomerPage/CustomerHeader.tsx b/clients/apps/web/src/components/Customer/CustomerPage/CustomerHeader.tsx index 5e99efbbe73..b123fe2a802 100644 --- a/clients/apps/web/src/components/Customer/CustomerPage/CustomerHeader.tsx +++ b/clients/apps/web/src/components/Customer/CustomerPage/CustomerHeader.tsx @@ -198,11 +198,12 @@ export const CustomerHeader = ({ Edit Customer - {organization.feature_settings?.config_as_code_enabled && ( - - Add Credits - - )} + {canManageCustomers && + organization.feature_settings?.config_as_code_enabled && ( + + Add Credits + + )} Delete Customer diff --git a/clients/apps/web/src/components/Customer/CustomerPage/CustomerUsageView.tsx b/clients/apps/web/src/components/Customer/CustomerPage/CustomerUsageView.tsx index 7c397ac37a5..d420f1c87dd 100644 --- a/clients/apps/web/src/components/Customer/CustomerPage/CustomerUsageView.tsx +++ b/clients/apps/web/src/components/Customer/CustomerPage/CustomerUsageView.tsx @@ -4,6 +4,7 @@ import { AddCreditsModal } from '@/components/Customer/AddCredits/AddCreditsModa import { useModal } from '@/components/Modal/useModal' import { estimateMeteredCost } from '@/components/Products/ProductForm/Pricing/utils' import { isMeteredPrice, MeteredPrice } from '@/utils/product' +import { useHasPermission } from '@/hooks/permissions' import { useCustomerMeters } from '@/hooks/queries/customerMeters' import { useMultipleMeterQuantities } from '@/hooks/queries/meters' import { useSubscriptions } from '@/hooks/queries/subscriptions' @@ -70,6 +71,9 @@ export const CustomerUsageView = ({ }) => { const router = useRouter() const addCreditsModal = useModal() + const canAddCredits = + useHasPermission(organization.id, 'customers:manage') && + !!organization.feature_settings?.config_as_code_enabled const [creditMeterId, setCreditMeterId] = useState() const { dateRange, interval } = useCustomerMetricsParams(customer) const { data: customerMetersData, isLoading } = useCustomerMeters( @@ -252,7 +256,7 @@ export const CustomerUsageView = ({ ) }, }, - ...(organization.feature_settings?.config_as_code_enabled + ...(canAddCredits ? [ { id: 'add_credits', From b7cbc734e5d1f9b04e47141eefb0d31ddefa5dae Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 18:17:05 +0200 Subject: [PATCH 16/29] clients/web: Keep the add credits summary a fixed height --- .../Customer/AddCredits/AddCreditsModal.tsx | 4 +- .../Customer/AddCredits/AddCreditsSummary.tsx | 50 +++++++------------ 2 files changed, 21 insertions(+), 33 deletions(-) diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx index 9da78d5d9f9..7a95b70b0c5 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx @@ -171,7 +171,7 @@ export const AddCreditsModal = ({ )} /> {(option || (isLoading && meterId)) && ( - + )} @@ -186,7 +186,7 @@ export const AddCreditsModal = ({ disabled={!option || !units} loading={creditCustomerMeter.isPending} > - {units ? `Add ${formatCredits(units)}` : 'Add credits'} + Add credits diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx index aad534dc412..75c3c0d53b4 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsSummary.tsx @@ -20,41 +20,29 @@ const SummaryRow = ({ ) -const SummaryCard = ({ children }: { children: ReactNode }) => ( - - {children} - -) - export const AddCreditsSummary = ({ option, units, }: { option?: MeterCreditOption - units: number | null + units: number }) => { - const currentBalance = ( - : '—'} - /> - ) - - if (!option || !units) { - return {currentBalance} - } - - const { balanceAfter, overage } = getCreditPreview(option, units) + const preview = option ? getCreditPreview(option, units) : null + const balanceValue = (value: number) => + option ? : '—' return ( - - {currentBalance} + + } + value={balanceValue(preview?.balanceAfter ?? 0)} /> - {overage && ( + {preview?.overage && ( )} - + ) } From 51b7f8e17c989a36855f4666f171130bbc92682a Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 18:18:38 +0200 Subject: [PATCH 17/29] clients/web: Name the customer in the add credits panel --- .../Customer/AddCredits/AddCreditsModal.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx index 7a95b70b0c5..af3d10f8859 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsModal.tsx @@ -64,6 +64,7 @@ export const AddCreditsModal = ({ const values = useWatch({ control: form.control }) const option = options.find((o) => o.meter.id === values.meter_id) const units = parseCreditUnits(values.units ?? '') + const customerLabel = customer.name || customer.email || 'this customer' const onSubmit = async (submitted: AddCreditsForm) => { const submittedUnits = parseCreditUnits(submitted.units) @@ -96,7 +97,7 @@ export const AddCreditsModal = ({ toast({ title: 'Credits Added', - description: `Added ${formatCredits(submittedUnits)} on ${option.meter.name} for ${customer.name || customer.email || 'this customer'}.`, + description: `Added ${formatCredits(submittedUnits)} on ${option.meter.name} for ${customerLabel}.`, }) onClose() } @@ -174,8 +175,16 @@ export const AddCreditsModal = ({ )} - - Credits don't expire and can't be removed once added. + + Grant{' '} + + {formatCredits(units ?? 0)} + {' '} + to{' '} + + {customerLabel} + + . Credits don't expire and can't be removed once added. - -
-
- -
- -
} >
-
- -
-

- {(customer.name?.length ?? 0) > 0 ? customer.name : '—'} -

-
- {customer.email ?? '—'} -
-
-
{customerMeter && (
{subscription?.current_period_end && ( diff --git a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx index 09ba8890c9a..2beaf16b37d 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx +++ b/clients/apps/web/src/components/Customer/AddCredits/AddCreditsButton.tsx @@ -29,9 +29,7 @@ export const AddCreditsButton = ({ return ( <> - + ({ mode: 'onChange', - defaultValues: { meter_id: meterId ?? '', units: '' }, + defaultValues: { meter_id: meterId ?? '', units: '', rollover: true }, }) const values = useWatch({ control: form.control }) @@ -74,7 +76,7 @@ export const AddCreditsModal = ({ customer_id: customer.id, meter_id: option.meter.id, units: submittedUnits, - rollover: true, + rollover: submitted.rollover, external_id: externalId, }) @@ -152,25 +154,44 @@ export const AddCreditsModal = ({ )} /> - ( - - Credits to add - - - - - - )} - /> + + ( + + Credits + + + + + + )} + /> + ( + + + + field.onChange(checked === true) + } + /> + + + )} + /> + {(option || (isLoading && meterId)) && ( )} @@ -184,7 +205,11 @@ export const AddCreditsModal = ({ {customerLabel} - . Credits don't expire and can't be removed once added. + .{' '} + {values.rollover + ? "Credits don't expire" + : 'Unused credits expire at the end of the billing period'}{' '} + and can't be removed once added. From d72626fbb18d1ce0c88c7d106754b7ea3683c842 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Wed, 14 Oct 2026 08:29:09 +0200 Subject: [PATCH 20/29] Poll for changes --- .../web/src/hooks/queries/customerMeters.ts | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/clients/apps/web/src/hooks/queries/customerMeters.ts b/clients/apps/web/src/hooks/queries/customerMeters.ts index 7c9858db22d..a6061b8743b 100644 --- a/clients/apps/web/src/hooks/queries/customerMeters.ts +++ b/clients/apps/web/src/hooks/queries/customerMeters.ts @@ -24,14 +24,63 @@ export const useCustomerMeters = ( retry: defaultRetry, }) +const BALANCE_POLL_INTERVAL_MS = 3_000 +const BALANCE_POLL_TIMEOUT_MS = 60_000 + +const getCreditedUnits = async (customerId: string, meterId: string) => { + try { + const { data } = await api.GET('/v1/customer-meters/', { + params: { + query: { customer_id: customerId, meter_id: meterId, limit: 1 }, + }, + }) + return data?.items[0]?.credited_units + } catch { + return undefined + } +} + +const waitForCreditedUnitsChange = async ( + customerId: string, + meterId: string, + previousCreditedUnits: number | undefined, +) => { + const deadline = Date.now() + BALANCE_POLL_TIMEOUT_MS + while (Date.now() < deadline) { + await new Promise((resolve) => + setTimeout(resolve, BALANCE_POLL_INTERVAL_MS), + ) + const creditedUnits = await getCreditedUnits(customerId, meterId) + if (creditedUnits !== previousCreditedUnits) return + } +} + export const useCreditCustomerMeter = () => useMutation({ + onMutate: async ({ + customer_id, + meter_id, + }: schemas['CustomerMeterCreditsCustomerIDCreate']) => ({ + previousCreditedUnits: meter_id + ? await getCreditedUnits(customer_id, meter_id) + : undefined, + }), mutationFn: (body: schemas['CustomerMeterCreditsCustomerIDCreate']) => api.POST('/v1/customer-meters/credits', { body }), - onSuccess: async (result) => { + onSuccess: (result, { customer_id, meter_id }, onMutateResult) => { if (result.error) return const queryClient = getQueryClient() - queryClient.invalidateQueries({ queryKey: ['customer_meters'] }) queryClient.invalidateQueries({ queryKey: ['events'] }) + if (!meter_id) { + queryClient.invalidateQueries({ queryKey: ['customer_meters'] }) + return + } + void waitForCreditedUnitsChange( + customer_id, + meter_id, + onMutateResult?.previousCreditedUnits, + ).finally(() => + queryClient.invalidateQueries({ queryKey: ['customer_meters'] }), + ) }, }) From a8af20f8267212ee837ba8d5445a96ade262c140 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Wed, 14 Oct 2026 08:35:11 +0200 Subject: [PATCH 21/29] clients/web: Match the usage table's subscription and wait for pricing in add credits --- .../AddCredits/useMeterCreditOptions.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts b/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts index 3cbdb8bb854..a3945471456 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts +++ b/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts @@ -20,23 +20,20 @@ export const useMeterCreditOptions = ( customer_id: customer.id, limit: 100, }) - const { data: subscriptions } = useSubscriptions(organizationId, { - customer_id: customer.id, - active: true, - }) + const { data: subscriptions, isLoading: subscriptionsLoading } = + useSubscriptions(organizationId, { + customer_id: customer.id, + active: true, + }) const options = useMemo((): MeterCreditOption[] => { const balanceByMeter = new Map( customerMeters?.items.map((cm) => [cm.meter_id, cm.balance]), ) - const subscriptionByMeter = new Map( - subscriptions?.items.flatMap((subscription) => - subscription.meters.map((m) => [m.meter_id, subscription] as const), - ), - ) - return (meters?.items ?? []).map((meter) => { - const subscription = subscriptionByMeter.get(meter.id) + const subscription = subscriptions?.items.find((sub) => + sub.meters.some((m) => m.meter_id === meter.id), + ) const unitPrice = subscription?.product.prices.find( (price): price is MeteredPrice => isMeteredPrice(price) && price.meter_id === meter.id, @@ -50,5 +47,8 @@ export const useMeterCreditOptions = ( }) }, [meters, customerMeters, subscriptions]) - return { options, isLoading: metersLoading || customerMetersLoading } + return { + options, + isLoading: metersLoading || customerMetersLoading || subscriptionsLoading, + } } From bdd5a12405f2fe44b25531e67ec8098704ae048f Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Wed, 14 Oct 2026 08:35:11 +0200 Subject: [PATCH 22/29] clients/web: Wrap the customer meter toolbar and keep the customer fallback On mobile, Add credits sits on the title row. --- .../meter/[meterId]/CustomerMeterPage.tsx | 27 +++++++++++++------ .../AddCredits/useMeterCreditOptions.ts | 1 + .../CustomerPage/CustomerUsageView.tsx | 2 +- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/[customerId]/meter/[meterId]/CustomerMeterPage.tsx b/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/[customerId]/meter/[meterId]/CustomerMeterPage.tsx index b6417f9283c..45506dcac3c 100644 --- a/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/[customerId]/meter/[meterId]/CustomerMeterPage.tsx +++ b/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/[customerId]/meter/[meterId]/CustomerMeterPage.tsx @@ -167,6 +167,14 @@ const CustomerMeterPage = ({ } }, [customerMeter, unitPrice]) + const addCreditsButton = ( + + ) + return ( - {customer.name || customer.email} + {customer.name || customer.email || 'Customer'} {subscription && ` · ${subscription.product.name}`} + + {addCreditsButton} + -
+
- -
+ {addCreditsButton} +
} > diff --git a/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts b/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts index a3945471456..d1c74ccf0e1 100644 --- a/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts +++ b/clients/apps/web/src/components/Customer/AddCredits/useMeterCreditOptions.ts @@ -24,6 +24,7 @@ export const useMeterCreditOptions = ( useSubscriptions(organizationId, { customer_id: customer.id, active: true, + limit: 100, }) const options = useMemo((): MeterCreditOption[] => { diff --git a/clients/apps/web/src/components/Customer/CustomerPage/CustomerUsageView.tsx b/clients/apps/web/src/components/Customer/CustomerPage/CustomerUsageView.tsx index d420f1c87dd..79e1716c1a4 100644 --- a/clients/apps/web/src/components/Customer/CustomerPage/CustomerUsageView.tsx +++ b/clients/apps/web/src/components/Customer/CustomerPage/CustomerUsageView.tsx @@ -83,7 +83,7 @@ export const CustomerUsageView = ({ const { data: subscriptionsData } = useSubscriptions( customer.organization_id, - { customer_id: customer.id, active: true }, + { customer_id: customer.id, active: true, limit: 100 }, ) const customerMeters = useMemo((): CustomerMeterWithSubscription[] => { From 73c4feacbed1ce1e2dbd40ac4fafa750ccbad8c2 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 17:30:22 +0200 Subject: [PATCH 23/29] sdk: Regenerate for crediting customer meters --- sdk/python/polar/v2027_01/errors.py | 19 +++ sdk/python/polar/v2027_01/inputs.py | 45 ++++++ sdk/python/polar/v2027_01/outputs.py | 7 + .../v2027_01/services/customer_meters.py | 135 ++++++++++++++++++ sdk/typescript/src/2027-01/errors.ts | 17 +++ sdk/typescript/src/2027-01/models.ts | 75 ++++++++++ .../src/2027-01/services/customer_meters.ts | 51 ++++++- 7 files changed, 348 insertions(+), 1 deletion(-) diff --git a/sdk/python/polar/v2027_01/errors.py b/sdk/python/polar/v2027_01/errors.py index 05c1a7e390a..7f204d3fa01 100644 --- a/sdk/python/polar/v2027_01/errors.py +++ b/sdk/python/polar/v2027_01/errors.py @@ -22,6 +22,9 @@ from polar.v2027_01.outputs import ( CheckoutLocked as CheckoutLockedModel, ) +from polar.v2027_01.outputs import ( + CustomerMeterCreditExternalIDConflict as CustomerMeterCreditExternalIDConflictModel, +) from polar.v2027_01.outputs import ( CustomerNotReady as CustomerNotReadyModel, ) @@ -1002,3 +1005,19 @@ class AmbiguousExternalMeterID(PolarClientError): def __init__(self, status_code: int, error: AmbiguousExternalMeterIDModel) -> None: self.error = error super().__init__(status_code, error) + + +class CustomerMetersCredits409Error(PolarClientError): + error_type = ( + CustomerMeterCreditExternalIDConflictModel | AmbiguousExternalCustomerIDModel + ) + error: CustomerMeterCreditExternalIDConflictModel | AmbiguousExternalCustomerIDModel + + def __init__( + self, + status_code: int, + error: CustomerMeterCreditExternalIDConflictModel + | AmbiguousExternalCustomerIDModel, + ) -> None: + self.error = error + super().__init__(status_code, error) diff --git a/sdk/python/polar/v2027_01/inputs.py b/sdk/python/polar/v2027_01/inputs.py index 33a4754f194..e76cd90e0d4 100644 --- a/sdk/python/polar/v2027_01/inputs.py +++ b/sdk/python/polar/v2027_01/inputs.py @@ -1598,6 +1598,51 @@ class CustomerIndividualCreate(typing.TypedDict): """The email address of the customer. This must be unique within the organization.""" +class CustomerMeterCreditsCustomerExternalIDCreate(typing.TypedDict): + """Schema to manually grant credits on a customer meter + using an external customer ID.""" + + meter_id: typing.NotRequired[str | None] + """The ID of the meter. Required if `external_meter_id` is not provided.""" + + external_meter_id: typing.NotRequired[str | None] + """The external ID of the meter, as set by config as code. Required if `meter_id` is not provided.""" + + units: int + """The number of units to credit.""" + + rollover: typing.NotRequired[bool] + """Whether unused credits carry over to the next meter period.""" + + external_id: typing.NotRequired[str | None] + """Your unique identifier for this credit. Retrying with the same `external_id` doesn't grant the credit twice.""" + + external_customer_id: str + """The external ID of the customer.""" + + +class CustomerMeterCreditsCustomerIDCreate(typing.TypedDict): + """Schema to manually grant credits on a customer meter using a customer ID.""" + + meter_id: typing.NotRequired[str | None] + """The ID of the meter. Required if `external_meter_id` is not provided.""" + + external_meter_id: typing.NotRequired[str | None] + """The external ID of the meter, as set by config as code. Required if `meter_id` is not provided.""" + + units: int + """The number of units to credit.""" + + rollover: typing.NotRequired[bool] + """Whether unused credits carry over to the next meter period.""" + + external_id: typing.NotRequired[str | None] + """Your unique identifier for this credit. Retrying with the same `external_id` doesn't grant the credit twice.""" + + customer_id: str + """The ID of the customer.""" + + class CustomerOrderConfirmPayment(typing.TypedDict): """Schema to confirm a retry payment using either a saved payment method or a new confirmation token.""" diff --git a/sdk/python/polar/v2027_01/outputs.py b/sdk/python/polar/v2027_01/outputs.py index 44bceb76b7b..4aa3528b03f 100644 --- a/sdk/python/polar/v2027_01/outputs.py +++ b/sdk/python/polar/v2027_01/outputs.py @@ -4399,6 +4399,13 @@ class CustomerMeter: """The meter associated with this customer.""" +@dataclasses.dataclass(kw_only=True, slots=True) +class CustomerMeterCreditExternalIDConflict: + error: typing.Literal["CustomerMeterCreditExternalIDConflict"] + + detail: str + + @dataclasses.dataclass(kw_only=True, slots=True) class CustomerNotReady: error: typing.Literal["CustomerNotReady"] diff --git a/sdk/python/polar/v2027_01/services/customer_meters.py b/sdk/python/polar/v2027_01/services/customer_meters.py index b6fe0897880..e2326da7a2d 100644 --- a/sdk/python/polar/v2027_01/services/customer_meters.py +++ b/sdk/python/polar/v2027_01/services/customer_meters.py @@ -10,15 +10,22 @@ parse_response_json, ) from polar.v2027_01.errors import ( + CustomerMetersCredits409Error, HTTPValidationError, + NotPermitted, ResourceNotFound, ) +from polar.v2027_01.inputs import ( + CustomerMeterCreditsCustomerExternalIDCreate, + CustomerMeterCreditsCustomerIDCreate, +) from polar.v2027_01.literals import ( CustomerMeterSortProperty, ) from polar.v2027_01.outputs import ( CustomerMeter, ListResourceCustomerMeter, + MeterCreditEvent, ) @@ -185,6 +192,70 @@ def get( } return parse_response_json(response, CustomerMeter, method_errors) + @typing.overload + def credits( + self, + *, + request_timeout: RequestTimeout | None = None, + request_access_token: str | None = None, + **kwargs: typing.Unpack[CustomerMeterCreditsCustomerIDCreate], + ) -> MeterCreditEvent: ... + + @typing.overload + def credits( + self, + *, + request_timeout: RequestTimeout | None = None, + request_access_token: str | None = None, + **kwargs: typing.Unpack[CustomerMeterCreditsCustomerExternalIDCreate], + ) -> MeterCreditEvent: ... + + def credits( + self, + *, + request_timeout: RequestTimeout | None = None, + request_access_token: str | None = None, + **kwargs: typing.Any, + ) -> MeterCreditEvent: + """ + Manually grant credits on a customer meter. + + Returns the `meter.credited` event. The customer meter balance is updated + asynchronously, shortly after the request. + + **Scopes**: `customers:write` + + Args: + request_timeout: Timeout override for this request, in seconds or as an httpx.Timeout instance. + request_access_token: Access token override for this request. + + **kwargs: Request body parameters + + Raises: + NotPermitted: You don't have permission to manage customers. + CustomerMetersCredits409Error: The external_id is already used by a different event, or the external customer ID matches customers in several accessible organizations. + HTTPValidationError: Validation Error + PolarNetworkError: Raised when a network error occurs while making the request. + PolarRateLimitError: Raised when the rate limit is exceeded. + PolarServerError: Raised when the server returns a 5xx error response. + """ + request = self.client.build_request( + method="POST", + url="/v1/customer-meters/credits", + path_params={}, + query_params={}, + request_timeout=request_timeout, + request_access_token=request_access_token, + body=kwargs, + ) + response = self.client.send_request(request) + method_errors = { + 403: NotPermitted, + 409: CustomerMetersCredits409Error, + 422: HTTPValidationError, + } + return parse_response_json(response, MeterCreditEvent, method_errors) + class CustomerMetersAsync(AsyncServiceBase): async def list( @@ -349,3 +420,67 @@ async def get( 422: HTTPValidationError, } return parse_response_json(response, CustomerMeter, method_errors) + + @typing.overload + async def credits( + self, + *, + request_timeout: RequestTimeout | None = None, + request_access_token: str | None = None, + **kwargs: typing.Unpack[CustomerMeterCreditsCustomerIDCreate], + ) -> MeterCreditEvent: ... + + @typing.overload + async def credits( + self, + *, + request_timeout: RequestTimeout | None = None, + request_access_token: str | None = None, + **kwargs: typing.Unpack[CustomerMeterCreditsCustomerExternalIDCreate], + ) -> MeterCreditEvent: ... + + async def credits( + self, + *, + request_timeout: RequestTimeout | None = None, + request_access_token: str | None = None, + **kwargs: typing.Any, + ) -> MeterCreditEvent: + """ + Manually grant credits on a customer meter. + + Returns the `meter.credited` event. The customer meter balance is updated + asynchronously, shortly after the request. + + **Scopes**: `customers:write` + + Args: + request_timeout: Timeout override for this request, in seconds or as an httpx.Timeout instance. + request_access_token: Access token override for this request. + + **kwargs: Request body parameters + + Raises: + NotPermitted: You don't have permission to manage customers. + CustomerMetersCredits409Error: The external_id is already used by a different event, or the external customer ID matches customers in several accessible organizations. + HTTPValidationError: Validation Error + PolarNetworkError: Raised when a network error occurs while making the request. + PolarRateLimitError: Raised when the rate limit is exceeded. + PolarServerError: Raised when the server returns a 5xx error response. + """ + request = self.client.build_request( + method="POST", + url="/v1/customer-meters/credits", + path_params={}, + query_params={}, + request_timeout=request_timeout, + request_access_token=request_access_token, + body=kwargs, + ) + response = await self.client.send_request(request) + method_errors = { + 403: NotPermitted, + 409: CustomerMetersCredits409Error, + 422: HTTPValidationError, + } + return parse_response_json(response, MeterCreditEvent, method_errors) diff --git a/sdk/typescript/src/2027-01/errors.ts b/sdk/typescript/src/2027-01/errors.ts index 1e70c52c33c..d7c9943555c 100644 --- a/sdk/typescript/src/2027-01/errors.ts +++ b/sdk/typescript/src/2027-01/errors.ts @@ -6,6 +6,7 @@ import type { CannotCreateOrganizationError as CannotCreateOrganizationErrorModel, CheckoutForbiddenError as CheckoutForbiddenErrorModel, CheckoutLocked as CheckoutLockedModel, + CustomerMeterCreditExternalIDConflict as CustomerMeterCreditExternalIDConflictModel, CustomerNotReady as CustomerNotReadyModel, DisputeAutoAcceptNotEnabled as DisputeAutoAcceptNotEnabledModel, DisputeNotOpenError as DisputeNotOpenErrorModel, @@ -1151,3 +1152,19 @@ export class AmbiguousExternalMeterID extends PolarClientError { + constructor( + public readonly statusCode: 409, + public readonly error: + | CustomerMeterCreditExternalIDConflictModel + | AmbiguousExternalCustomerIDModel, + ) { + super(statusCode, error); + this.name = "CustomerMetersCredits409Error"; + } +} diff --git a/sdk/typescript/src/2027-01/models.ts b/sdk/typescript/src/2027-01/models.ts index 3a0ac9bb530..f1e9c75f1a1 100644 --- a/sdk/typescript/src/2027-01/models.ts +++ b/sdk/typescript/src/2027-01/models.ts @@ -10565,6 +10565,81 @@ export interface CustomerMeter { meter: Meter; } +/** + * CustomerMeterCreditExternalIDConflict + */ +export interface CustomerMeterCreditExternalIDConflict { + /** + * error + */ + error: "CustomerMeterCreditExternalIDConflict"; + /** + * detail + */ + detail: string; +} + +/** + * Schema to manually grant credits on a customer meter +using an external customer ID. + */ +export interface CustomerMeterCreditsCustomerExternalIDCreate { + /** + * The ID of the meter. Required if `external_meter_id` is not provided. + */ + meter_id?: string | null; + /** + * The external ID of the meter, as set by config as code. Required if `meter_id` is not provided. + */ + external_meter_id?: string | null; + /** + * The number of units to credit. + */ + units: number; + /** + * Whether unused credits carry over to the next meter period. + */ + rollover?: boolean; + /** + * Your unique identifier for this credit. Retrying with the same `external_id` doesn't grant the credit twice. + */ + external_id?: string | null; + /** + * The external ID of the customer. + */ + external_customer_id: string; +} + +/** + * Schema to manually grant credits on a customer meter using a customer ID. + */ +export interface CustomerMeterCreditsCustomerIDCreate { + /** + * The ID of the meter. Required if `external_meter_id` is not provided. + */ + meter_id?: string | null; + /** + * The external ID of the meter, as set by config as code. Required if `meter_id` is not provided. + */ + external_meter_id?: string | null; + /** + * The number of units to credit. + */ + units: number; + /** + * Whether unused credits carry over to the next meter period. + */ + rollover?: boolean; + /** + * Your unique identifier for this credit. Retrying with the same `external_id` doesn't grant the credit twice. + */ + external_id?: string | null; + /** + * The ID of the customer. + */ + customer_id: string; +} + /** * CustomerNotReady */ diff --git a/sdk/typescript/src/2027-01/services/customer_meters.ts b/sdk/typescript/src/2027-01/services/customer_meters.ts index 39b75758ab8..1b1a43bc178 100644 --- a/sdk/typescript/src/2027-01/services/customer_meters.ts +++ b/sdk/typescript/src/2027-01/services/customer_meters.ts @@ -1,11 +1,19 @@ import type { ClientBase, RequestOptions } from "../../base"; import type { CustomerMeter, + CustomerMeterCreditsCustomerExternalIDCreate, + CustomerMeterCreditsCustomerIDCreate, CustomerMeterSortProperty, ListResourceCustomerMeter, + MeterCreditEvent, } from "../models"; -import { HTTPValidationError, ResourceNotFound } from "../errors"; +import { + CustomerMetersCredits409Error, + HTTPValidationError, + NotPermitted, + ResourceNotFound, +} from "../errors"; export const listCustomerMeters = (client: ClientBase) => { /** @@ -136,11 +144,52 @@ export const getCustomerMeters = (client: ClientBase) => { }); }; }; +export const creditsCustomerMeters = (client: ClientBase) => { + /** + * Manually grant credits on a customer meter. + * + * Returns the `meter.credited` event. The customer meter balance is updated + * asynchronously, shortly after the request. + * + * **Scopes**: `customers:write` + * + * @param body - Request body + * @param requestOptions - Request options + * @returns {MeterCreditEvent} + * @throws {PolarNetworkError} When a network error occurs + * @throws {PolarRateLimitError} When the rate limit is exceeded + * @throws {PolarServerError} When the server returns a 5xx error + * @throws {NotPermitted} You don't have permission to manage customers. + * @throws {CustomerMetersCredits409Error} The external_id is already used by a different event, or the external customer ID matches customers in several accessible organizations. + * @throws {HTTPValidationError} Validation Error + */ + return async ( + body: CustomerMeterCreditsCustomerIDCreate | CustomerMeterCreditsCustomerExternalIDCreate, + requestOptions?: RequestOptions, + ): Promise => { + const pathParams = {}; + const queryParams = {}; + const request = client.buildRequest( + "POST", + "/v1/customer-meters/credits", + pathParams, + queryParams, + body, + ); + const response = await client.sendRequest(request, requestOptions); + return client.parseResponse(response, "json", { + 403: NotPermitted, + 409: CustomerMetersCredits409Error, + 422: HTTPValidationError, + }); + }; +}; export function createCustomerMetersService(client: ClientBase) { return { list: listCustomerMeters(client), get: getCustomerMeters(client), + credits: creditsCustomerMeters(client), iterList: iterListCustomerMeters(client), }; } From 66661c7957eb1a220adde1b7a76166c5a67a03a2 Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Tue, 13 Oct 2026 17:31:25 +0200 Subject: [PATCH 24/29] polar: Grant meter credits through the runtime actor --- .../packages/polar/src/client/actor.test.ts | 129 +++++++++++++++++- clients/packages/polar/src/client/actor.ts | 53 ++++++- .../polar/src/internal/api/customer-meters.ts | 35 ++++- 3 files changed, 208 insertions(+), 9 deletions(-) diff --git a/clients/packages/polar/src/client/actor.test.ts b/clients/packages/polar/src/client/actor.test.ts index 8a3f019bd25..062fb6b6cbd 100644 --- a/clients/packages/polar/src/client/actor.test.ts +++ b/clients/packages/polar/src/client/actor.test.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { z } from 'zod' import type { RuntimeSDKConfig } from '../schema/runtime' -import type { Polar } from '../sdk' +import { errors, type Polar } from '../sdk' import { createActor, EventValidationError } from './actor' const config = { @@ -232,3 +232,130 @@ describe('actor events', () => { expect(ingest).not.toHaveBeenCalled() }) }) + +describe('actor grants', () => { + const meters = { + tool_call: { + filter: { + conjunction: 'and', + clauses: [{ property: 'name', operator: 'eq', value: 'tool_call' }], + }, + aggregation: { func: 'count' }, + }, + } as const satisfies RuntimeSDKConfig['meters'] + + const setup = () => { + const credits = vi + .fn() + .mockResolvedValue({ timestamp: '2026-10-01T12:00:00Z' }) + const list = vi.fn() + const sdk = { customerMeters: { credits, list } } as unknown as Polar + return { actor: createActor({ meters }, sdk), credits, list } + } + + it('accepts only configured meter names', () => { + const { actor } = setup() + expectTypeOf(actor({ customerId: 'customer-id' }).grant) + .parameter(0) + .toEqualTypeOf<'tool_call'>() + }) + + it.each([ + [{ customerId: 'customer-id' }, { customer_id: 'customer-id' }], + [ + { externalCustomerId: 'external-customer-id' }, + { external_customer_id: 'external-customer-id' }, + ], + ])('credits the customer %j with rollover', async (identifier, body) => { + const { actor, credits } = setup() + + await actor(identifier).grant('tool_call', 100) + expect(credits).toHaveBeenCalledWith({ + ...body, + external_meter_id: 'tool_call', + units: 100, + rollover: true, + }) + }) + + it('credits the customer of a member actor', async () => { + const { actor, credits } = setup() + + await actor({ + externalCustomerId: 'external-customer-id', + externalMemberId: 'external-member-id', + }).grant('tool_call', 100) + expect(credits).toHaveBeenCalledWith({ + external_customer_id: 'external-customer-id', + external_meter_id: 'tool_call', + units: 100, + rollover: true, + }) + }) + + it('passes rollover and the idempotency key', async () => { + const { actor, credits } = setup() + + await actor({ customerId: 'customer-id' }).grant('tool_call', 100, { + rollover: false, + idempotencyKey: 'order-1', + }) + expect(credits).toHaveBeenCalledWith({ + customer_id: 'customer-id', + external_meter_id: 'tool_call', + units: 100, + rollover: false, + external_id: 'order-1', + }) + }) + + it('reports the balance as not pristine until the credit is applied', async () => { + const { actor, list } = setup() + const customer = actor({ customerId: 'customer-id' }) + const customerMeter = (modifiedAt: string, balance: number) => ({ + items: [{ balance, created_at: modifiedAt, modified_at: modifiedAt }], + }) + + await customer.grant('tool_call', 100) + + list.mockResolvedValueOnce(customerMeter('2026-10-01T11:59:59Z', 0)) + await expect(customer.balance('tool_call')).resolves.toEqual({ + balance: 0, + pristine: false, + }) + list.mockResolvedValueOnce(customerMeter('2026-10-01T12:00:01Z', 100)) + await expect(customer.balance('tool_call')).resolves.toEqual({ + balance: 100, + pristine: true, + }) + }) + + it('rejects undeclared meters at runtime', async () => { + const { actor, credits } = setup() + + await expect( + actor({ customerId: 'customer-id' }).grant('nope' as 'tool_call', 100), + ).rejects.toThrow('Unknown meter: nope') + expect(credits).not.toHaveBeenCalled() + }) + + it('rejects a meter that is not deployed', async () => { + const credits = vi.fn().mockRejectedValue( + new errors.HTTPValidationError(422, { + detail: [ + { + loc: ['body', 'external_meter_id'], + msg: 'Meter not found', + type: 'value_error', + }, + ], + }), + ) + const sdk = { customerMeters: { credits } } as unknown as Polar + const customer = createActor({ meters }, sdk)({ customerId: 'customer-id' }) + + await expect(customer.grant('tool_call', 100)).rejects.toThrow( + 'not deployed', + ) + }) +}) diff --git a/clients/packages/polar/src/client/actor.ts b/clients/packages/polar/src/client/actor.ts index eaec37d6115..610e133714c 100644 --- a/clients/packages/polar/src/client/actor.ts +++ b/clients/packages/polar/src/client/actor.ts @@ -1,6 +1,9 @@ import type { StandardSchemaV1 } from '@standard-schema/spec' import { Data, Predicate } from 'effect' -import { getCustomerMeter } from '../internal/api/customer-meters' +import { + creditCustomerMeter, + getCustomerMeter, +} from '../internal/api/customer-meters' import { ingestEvents } from '../internal/api/events' import { assertBenefitDeployed, @@ -22,6 +25,13 @@ export type MeterBalance = { pristine: boolean } +export type GrantOptions = { + // Defaults to true, so unused credits carry over to the next meter period + rollover?: boolean + // Retrying with the same key doesn't grant the credits twice + idempotencyKey?: string +} + export type BenefitAccess = | { granted: true; metadata: Values } | { granted: false } @@ -110,6 +120,13 @@ export type Actor = ( name: Name, ) => Promise>> balance: (name: MeterName) => Promise + // Credits always go to the customer, even on an actor with a member. The + // balance updates asynchronously, like it does for tracked events. + grant: ( + name: MeterName, + units: number, + options?: GrantOptions, + ) => Promise track: { // Ingests all events in a single request (events: readonly TrackedEvent[]): Promise @@ -151,6 +168,18 @@ export const createActor = ( identifier.customerId !== undefined ? `customer:${identifier.customerId}:${externalMeterId}` : `external_customer:${identifier.externalCustomerId}:${externalMeterId}` + const markIngested = ( + identifier: ActorIdentifier, + externalMeterId: string, + timestamp: Date, + ) => { + const key = cacheKey(identifier, externalMeterId) + const current = latestIngestedAt.get(key) + + if (current === undefined || timestamp > current) { + latestIngestedAt.set(key, timestamp) + } + } const validateEvent = async ( name: string, @@ -202,12 +231,7 @@ export const createActor = ( matchesMeter(meter, { name, timestamp, metadata: metadata ?? {} }), ) ) { - const key = cacheKey(identifier, externalId) - const current = latestIngestedAt.get(key) - - if (current === undefined || timestamp > current) { - latestIngestedAt.set(key, timestamp) - } + markIngested(identifier, externalId, timestamp) } } } @@ -288,6 +312,21 @@ export const createActor = ( } }, + async grant(name, units, { rollover = true, idempotencyKey } = {}) { + if (config.meters?.[name] === undefined) { + throw new Error(`Unknown meter: ${name}`) + } + + const event = await creditCustomerMeter( + sdk, + toCustomerIdentifier(identifier), + name, + { units, rollover, externalId: idempotencyKey }, + ) + deployedMeters.add(name) + markIngested(identifier, name, new Date(event.timestamp)) + }, + track: (( nameOrEvents: string | readonly TrackedEvent[], metadata?: EventMetadata, diff --git a/clients/packages/polar/src/internal/api/customer-meters.ts b/clients/packages/polar/src/internal/api/customer-meters.ts index 7daf57e50de..c10b3ce6ec9 100644 --- a/clients/packages/polar/src/internal/api/customer-meters.ts +++ b/clients/packages/polar/src/internal/api/customer-meters.ts @@ -1,6 +1,39 @@ -import type { models, Polar } from '../../sdk' +import { errors, type models, type Polar } from '../../sdk' import type { CustomerIdentifier } from './utils' +export type CustomerMeterCredit = { + units: number + rollover: boolean + externalId?: string | undefined +} + +export const creditCustomerMeter = async ( + sdk: Polar, + identifier: CustomerIdentifier, + externalMeterId: string, + { units, rollover, externalId }: CustomerMeterCredit, +): Promise => { + try { + return await sdk.customerMeters.credits({ + ...identifier, + external_meter_id: externalMeterId, + units, + rollover, + ...(externalId !== undefined ? { external_id: externalId } : {}), + }) + } catch (error) { + if ( + error instanceof errors.HTTPValidationError && + error.error.detail?.some(({ loc }) => loc.at(-1) === 'external_meter_id') + ) { + throw new Error( + `Meter ${JSON.stringify(externalMeterId)} is not deployed in this environment.`, + ) + } + throw error + } +} + export const getCustomerMeter = async ( sdk: Polar, identifier: CustomerIdentifier, From 3f756b2050fdbe39b61a9cbde735d87e8f94542e Mon Sep 17 00:00:00 2001 From: Villiam Strandh Date: Wed, 14 Oct 2026 08:59:44 +0200 Subject: [PATCH 25/29] demo-app: Reward a survey answer with free tool calls --- clients/apps/demo-app/app/actions.ts | 11 +++ clients/apps/demo-app/app/demo.tsx | 112 +++++++++++++++++++++++++- clients/apps/demo-app/app/globals.css | 13 ++- clients/apps/demo-app/app/steps.ts | 21 ++++- clients/apps/demo-app/polar.ts | 16 ++++ 5 files changed, 167 insertions(+), 6 deletions(-) diff --git a/clients/apps/demo-app/app/actions.ts b/clients/apps/demo-app/app/actions.ts index ff6cea9a03a..286e91158cb 100644 --- a/clients/apps/demo-app/app/actions.ts +++ b/clients/apps/demo-app/app/actions.ts @@ -7,6 +7,7 @@ import { callTool, createProCheckout, ensureFreePlan, + grantSurveyCredits, isPro, polar, purchaseTopUp, @@ -66,6 +67,16 @@ export async function checkUpgraded() { return isPro(externalId) } +export async function claimSurveyCredits(answer: string) { + const externalId = await getExternalCustomerId() + if (!externalId) redirect('/') + if (typeof answer !== 'string' || answer.trim() === '') { + throw new Error('Missing survey answer') + } + + await grantSurveyCredits(externalId, answer.trim().slice(0, 1000)) +} + export async function topUp(pack: TopUp) { const externalId = await getExternalCustomerId() if (!externalId) redirect('/') diff --git a/clients/apps/demo-app/app/demo.tsx b/clients/apps/demo-app/app/demo.tsx index 575515b87e7..dd6a0734543 100644 --- a/clients/apps/demo-app/app/demo.tsx +++ b/clients/apps/demo-app/app/demo.tsx @@ -2,7 +2,14 @@ import { useEffect, useRef, useState } from 'react' import type { getTopUps } from '../polar' -import { call, checkUpgraded, getBalance, topUp, upgrade } from './actions' +import { + call, + checkUpgraded, + claimSurveyCredits, + getBalance, + topUp, + upgrade, +} from './actions' import { CodePanel, type Outcomes } from './code-panel' import type { HighlightedSteps } from './highlight' import type { Step } from './steps' @@ -39,6 +46,17 @@ const entriesOf = ( pending, })) +// Customers who claimed the survey credits: { [externalCustomerId]: true } +const SURVEY_KEY = 'survey_claimed' + +const readSurveyClaims = (): Record => { + try { + return JSON.parse(localStorage.getItem(SURVEY_KEY) ?? '{}') + } catch { + return {} + } +} + const readHistory = (): History => { try { return JSON.parse(localStorage.getItem(HISTORY_KEY) ?? '{}') @@ -64,7 +82,7 @@ const timeAgo = (at: number, now: number) => { const countOf = (event: { shiftKey: boolean; metaKey: boolean }) => event.shiftKey ? 100 : event.metaKey ? 10 : 1 -type Modal = 'paywall' | 'upgrading' | 'upgraded' | 'topup' +type Modal = 'paywall' | 'upgrading' | 'upgraded' | 'topup' | 'survey' type TopUpPack = Awaited>[number] @@ -109,6 +127,11 @@ export function Demo({ const [pack, setPack] = useState(topUps[0]?.id) const [purchasing, setPurchasing] = useState(false) const [purchaseError, setPurchaseError] = useState() + const [surveyAnswer, setSurveyAnswer] = useState('') + const [claiming, setClaiming] = useState(false) + const [claimError, setClaimError] = useState() + const [surveyClaimed, setSurveyClaimed] = useState(true) + const [paywallServer, setPaywallServer] = useState() const [balance, setBalance] = useState() const [pristine, setPristine] = useState(false) const [syncStartedAt, setSyncStartedAt] = useState() @@ -163,6 +186,7 @@ export function Demo({ ), ) setHistoryLoaded(true) + setSurveyClaimed(readSurveyClaims()[externalCustomerId] === true) }, [externalCustomerId]) useEffect(() => { @@ -233,6 +257,7 @@ export function Demo({ if (result.paywall) { setEntries((entries) => entries.filter((entry) => entry.at !== at)) + setPaywallServer(server) setModal('paywall') return } @@ -271,6 +296,42 @@ export function Demo({ setPristine(false) } + const openSurvey = () => { + setClaimError(undefined) + setModal('survey') + } + + const onClaimSurvey = async (event: React.FormEvent) => { + event.preventDefault() + setClaiming(true) + setClaimError(undefined) + + try { + await claimSurveyCredits(surveyAnswer) + } catch { + setClaimError('Something went wrong. Please try again.') + return + } finally { + setClaiming(false) + } + + try { + localStorage.setItem( + SURVEY_KEY, + JSON.stringify({ ...readSurveyClaims(), [externalCustomerId]: true }), + ) + } catch {} + setSurveyClaimed(true) + setActive({ step: 'grant' }) + setOutcome( + 'grant', + `grant('tool_call', 50, { idempotencyKey: 'survey:…' }) → credited`, + ) + setModal(undefined) + setSyncStartedAt(Date.now()) + setPristine(false) + } + const selectedPack = topUps.find((topUp) => topUp.id === pack) const disabled = calling || !pristine const syncSeconds = @@ -331,7 +392,8 @@ export function Demo({ { - if (modal === 'upgrading' || purchasing) event.preventDefault() + if (modal === 'upgrading' || purchasing || claiming) + event.preventDefault() }} onClose={() => setModal(undefined)} > @@ -349,12 +411,56 @@ export function Demo({ Get Pro for {proPrice} + {paywallServer === 'builtin' && !surveyClaimed && ( + + )}
)} + {content === 'survey' && ( +
+
+

Get 50 free tool calls

+

+ Tell us what you're building. One reward per account. +

+
+