diff --git a/docs/open-api-docs.yaml b/docs/open-api-docs.yaml index 2e30e57d..fc239c92 100644 --- a/docs/open-api-docs.yaml +++ b/docs/open-api-docs.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: The Agent's user-facing API description: The user-facing parts of The Agent's API service (excluding system-level endpoints, chat completion, maintenance endpoints, etc.) - version: 5.32.3 + version: 5.33.0 license: name: MIT url: https://opensource.org/licenses/MIT diff --git a/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/.openspec.yaml b/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/.openspec.yaml new file mode 100644 index 00000000..d7bc0110 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-10 diff --git a/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/design.md b/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/design.md new file mode 100644 index 00000000..0c61aaf7 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/design.md @@ -0,0 +1,101 @@ +## Context + +User profiles currently begin with a zero balance and persist EULA acceptance on the user row. EULA acceptance is irreversible, may also activate a waitlisted profile, and is saved independently of accounting operations. Credit transfers lock both user rows and commit their balance changes before creating a separately committed usage record. + +`THE_AGENT` is a persisted system user. Existing transfer history represents transfers as credit-transfer usage records with the sender as payer and the receiver as counterpart. Public peer transfers require a platform handle and reject sponsored senders and receivers, while welcome issuance must work by user ID for sponsored and non-sponsored profiles. + +The user creation date is stored as a calendar date. Eligibility therefore uses whole calendar-day age rather than elapsed hours. + +## Goals / Non-Goals + +**Goals:** +- Preserve an ordinary transfer from `THE_AGENT` as the visible accounting provenance. +- Make EULA acceptance, activation, balance changes, and transfer-history persistence atomic and idempotent. +- Reuse the ordinary credit balance and credit-transfer history semantics without applying public peer-transfer eligibility rules. +- Serialize concurrent grants to the same recipient through a recipient-row lock. + +**Non-Goals:** +- Introduce promotional credit buckets, expiration, restricted spending, or special refund treatment. +- Change API-key, sponsor-key, sponsor-credit, or receiver-credit precedence. +- Backfill profiles that accepted the EULA before deployment. +- Deduplicate welcome transfers when independently activated platform profiles are later connected. +- Replace the existing `max_users` counting and admission model. +- Generalize atomic transaction ownership across unrelated accounting operations. + +## Decisions + +### Configure amount and per-profile eligibility window together + +Add typed configuration values in `util/config.py` for: + +- `WELCOME_CREDIT_GRANT_AMOUNT`, default `500.0`; +- `WELCOME_CREDIT_GRANT_ELIGIBILITY_DAYS`, default `7`. + +A profile is eligible when its persisted EULA state changes from false to true and its age in whole calendar days is less than or equal to the configured window. The boundary is inclusive. Current balance, purchases, and sponsorships are deliberately excluded from eligibility because received credits are fungible and those signals do not reliably identify a new profile. + +An absolute rollout cutoff was considered. The per-profile age window was chosen because it directly expresses "new profile" and naturally excludes older unaccepted records. Profiles that accepted before deployment remain ineligible because there is no false-to-true transition. + +### Add a generic credit-grant operation to the transfer service + +Add `grant_credits(recipient, amount, commit, note=None)` to the existing credit transfer service rather than routing issuance through its public peer-transfer method. The required `commit` flag makes transaction ownership explicit at the production callsite. The recipient may be a persisted `User` or UUID. The operation locks `THE_AGENT` and the recipient in UUID order, adds the caller-provided amount to `THE_AGENT`, immediately moves that amount through the ordinary sender-minus/recipient-plus balance operation, and records the caller-provided note. With `commit=True`, it commits and sends a best-effort post-commit notification. With `commit=False`, it leaves both the commit and subsequent `notify_grant` call to the transaction-owning caller. + +The operation has no EULA, age-window, welcome-configuration, sponsorship, handle-resolution, or peer-transfer eligibility knowledge. This makes it reusable by future administrative APIs or tools while preserving user-visible provenance through the existing credit-transfer history model. + +### Own one transaction across acceptance and issuance + +The user-settings layer owns welcome eligibility and stages the EULA update before invoking the generic grant operation. The combined flow shall: + +1. lock `THE_AGENT` and the recipient in UUID order and read the recipient's current persisted EULA and creation state; +2. if the request would transition an unaccepted waitlisted profile, validate activation capacity before applying any settings changes; +3. apply the complete settings payload and, when activation is permitted, clear the waitlist and invitation flags; +4. determine welcome eligibility from the explicit persisted-false to updated-true EULA transition and the locked creation date; +5. persist the updated recipient without committing; +6. when eligible, call `grant_credits` with the updated recipient, configured welcome amount, note `"Welcome"`, and `commit=False`; the operation reuses the same transaction and lock order, temporarily credits `THE_AGENT`, executes the ordinary balance transfer, and stages transfer history; +7. commit the settings transaction unconditionally after the optional grant; +8. when eligible, send the best-effort grant notification after the caller-owned commit succeeds; +9. roll back the transaction if any validation, settings, balance, history, or commit operation fails. + +`THE_AGENT` remains the accounting source in transfer history. Its temporary top-up and transfer debit happen in the same transaction, so its final balance is unchanged and the top-up has no separate history record. Both rows use ordered pair locking. Repository and grant operations support explicit commit deferral while retaining their existing committed behavior where requested. + +### Use the irreversible EULA transition as the issuance marker + +The persisted false-to-true EULA transition is the one-time issuance marker. Repeated or concurrent requests re-read the row after acquiring the lock; only the request that observes false may issue the transfer. No balance or purchase heuristic is used. + +A separate grant marker was considered but rejected as unnecessary while EULA acceptance cannot be revoked or reset. If policy versioning later permits resetting acceptance, a durable welcome-grant identifier must be introduced before that reset ships. + +Before the first acceptance, the settings endpoint rejects payloads that omit policy acceptance so account setup cannot precede the required EULA action. `false` remains invalid for every profile. After acceptance, an omitted or `null` payload value retains normal PATCH semantics and leaves the persisted `true` state unchanged. + +### Record an ordinary transfer + +For an eligible acceptance, create the same transfer-history shape used by peer transfers: + +- sender, payer, and owner: `THE_AGENT`; +- receiver/counterpart: the activated profile; +- purpose: credit transfer; +- amount/total credit cost: configured welcome amount; +- note: `"Welcome"`; +- credit use: true; +- external tool costs and maintenance fee: zero. + +The record is visible from both the system agent and receiver transfer-history queries. The recipient's resulting balance is the existing undifferentiated credit balance. + +### Preserve additive profile merging + +Welcome eligibility and idempotency are per platform profile. If two profiles independently accept the EULA while eligible, each receives a transfer. Existing profile connection behavior sums their balances without recognizing or removing either transfer. + +## Risks / Trade-offs + +- **[Policy acceptance is reused as the idempotency marker]** → Keep acceptance irreversible; introduce a dedicated issuance marker before any future policy-version reset. +- **[Calendar-date storage makes the window coarser than elapsed time]** → Define and test eligibility in inclusive whole calendar days, matching the persisted data. +- **[Hidden transaction ownership could prematurely commit staged caller changes]** → Require an explicit `commit` argument on every grant and use `commit=False` when settings owns the transaction. +- **[Changing repository commit control can accidentally alter existing transactions]** → Make caller-owned commits opt-in and retain current defaults; cover both deferred and existing paths. +- **[A database failure could otherwise leave acceptance without history or balance]** → Keep all database mutations under one transaction and test rollback at the transfer-record boundary. +- **[Notification can fail after commit]** → Treat notification as best-effort; committed credits and history remain authoritative. +- **[Concurrent activation capacity checks remain count-based]** → Ordered pair locking prevents duplicate welcome issuance and grant deadlocks but does not redefine the existing admission model. + +## Migration Plan + +1. Deploy the new configuration defaults and transactional grant path together. +2. Do not modify existing balances and do not create transfer records for profiles whose EULA is already accepted. +3. Profiles whose EULA is still unaccepted are eligible only when their calendar-day age is within the configured window at acceptance. +4. Rollback disables future welcome transfers. Already committed transfers and balances remain ordinary user funds and are not reclaimed. diff --git a/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/proposal.md b/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/proposal.md new file mode 100644 index 00000000..cd938e16 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/proposal.md @@ -0,0 +1,31 @@ +## Why + +New platform profiles need enough ordinary credit to try the service without configuring an API key or purchasing credits first. Tying a one-time welcome transfer to timely EULA acceptance ensures credits are issued only to newly activated profiles rather than every record that appears in the database. + +## What Changes + +- Add configurable welcome-credit settings for the transfer amount, defaulting to 500 credits, and the acceptance eligibility window, defaulting to 7 days after profile creation. +- On the first persisted EULA acceptance within the eligibility window, invoke a generic transactional credit-grant operation with the configured welcome amount and note `"Welcome"`. +- Record the grant as a normal credit transfer from `THE_AGENT` to the recipient; after receipt, the credits have no origin-based classification, restrictions, expiration, or spending differences. +- Keep EULA acceptance, waitlist activation changes, recipient balance mutation, and transfer-record creation in one locked database transaction. +- Make the welcome transfer available to sponsored and non-sponsored recipients without applying peer-transfer restrictions; existing sponsorship billing precedence remains unchanged. +- Grant at most once per platform profile. Connected profiles retain additive balances and may therefore contribute multiple grants after merging. +- Do not grant credits to profiles that already accepted the EULA or that first accept it after the configured post-creation eligibility window. + +## Capabilities + +### New Capabilities +- `welcome-credit-grants`: Configurable, one-time welcome credit transfers issued when a newly created platform profile accepts the EULA within its eligibility window. + +### Modified Capabilities + +None. + +## Impact + +- Configuration gains welcome grant amount and post-creation eligibility-window values. +- User-settings EULA acceptance decides welcome eligibility and invokes the generic credit-grant operation with welcome-specific arguments. +- Accounting and user persistence gain a transaction spanning profile activation, recipient credit issuance, and transfer-history creation. +- Credit history exposes the welcome allocation as an ordinary transfer from `THE_AGENT`. +- Existing credit spending, purchases, refunds, transfers, sponsorship billing precedence, and profile-merge balance behavior remain unchanged. +- Tests must cover eligibility boundaries, sponsored recipients, one-time/idempotent issuance, transfer history, rollback atomicity, and the generic grant API. diff --git a/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/specs/welcome-credit-grants/spec.md b/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/specs/welcome-credit-grants/spec.md new file mode 100644 index 00000000..1b8c6626 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/specs/welcome-credit-grants/spec.md @@ -0,0 +1,98 @@ +## Purpose + +Define how newly created platform profiles receive a one-time, fully fungible welcome credit transfer after timely EULA acceptance. + +## ADDED Requirements + +### Requirement: Welcome grant configuration +The system SHALL configure the welcome transfer amount and the maximum profile age in whole calendar days at EULA acceptance. The default amount SHALL be 500 credits and the default maximum age SHALL be 7 days. + +#### Scenario: Default configuration +- **WHEN** no welcome grant configuration overrides are provided +- **THEN** the system uses a 500-credit transfer amount and a 7-day maximum profile age + +#### Scenario: Overridden configuration +- **WHEN** valid welcome grant configuration overrides are provided +- **THEN** the system uses the configured transfer amount and maximum profile age + +### Requirement: Policy acceptance precedes other settings +The system SHALL reject settings updates from an unaccepted profile unless the payload confirms policy acceptance with `true`. A payload value of `false` SHALL always be rejected. After policies have been accepted, an omitted or `null` acceptance field SHALL preserve the accepted state while allowing ordinary settings changes. + +#### Scenario: Unaccepted profile omits policy acceptance +- **WHEN** a profile that has not accepted policies submits other settings without `are_policies_accepted=true` +- **THEN** the settings update is rejected without persisting any changes + +#### Scenario: Accepted profile omits policy acceptance +- **WHEN** a profile that already accepted policies submits an ordinary settings update without the acceptance field +- **THEN** the other settings are saved and policy acceptance remains unchanged + +### Requirement: Grant on timely first EULA acceptance +The system SHALL issue one welcome transfer when a profile changes from not having accepted the EULA to having accepted it and the profile age is not greater than the configured maximum age. Eligibility SHALL depend on the profile lifecycle and SHALL NOT depend on its current credit balance, purchase history, or sponsorship status. + +#### Scenario: Eligible first acceptance +- **WHEN** a profile first accepts the EULA no later than 7 whole calendar days after its creation under the default configuration +- **THEN** the profile receives 500 credits + +#### Scenario: Acceptance at the eligibility boundary +- **WHEN** a profile first accepts the EULA exactly the configured number of whole calendar days after its creation +- **THEN** the profile receives the welcome transfer + +#### Scenario: Acceptance after the eligibility window +- **WHEN** a profile first accepts the EULA more than the configured number of whole calendar days after its creation +- **THEN** the EULA acceptance and any permitted activation still succeed without a welcome transfer + +#### Scenario: Existing balance does not affect eligibility +- **WHEN** an otherwise eligible profile first accepts the EULA while it already has credits or purchase history +- **THEN** the profile still receives the full configured welcome transfer + +#### Scenario: Sponsored recipient +- **WHEN** an otherwise eligible sponsored profile first accepts the EULA +- **THEN** the profile receives the welcome transfer despite ordinary peer-transfer restrictions on sponsored recipients + +### Requirement: One transfer per platform profile +The system SHALL issue at most one welcome transfer for each platform profile. A request that observes an already accepted EULA SHALL NOT issue another welcome transfer. + +#### Scenario: Repeated acceptance request +- **WHEN** a profile that already accepted the EULA submits settings with EULA acceptance again +- **THEN** the profile receives no additional welcome credits + +#### Scenario: Concurrent acceptance requests +- **WHEN** concurrent requests attempt the same profile's first EULA acceptance +- **THEN** exactly one request issues the welcome transfer and the profile receives the configured amount only once + +#### Scenario: Profiles later connected +- **WHEN** two independently eligible platform profiles each receive a welcome transfer and are later connected +- **THEN** both balances contribute additively to the merged profile + +### Requirement: Ordinary transferable credits +Credits received through the welcome transfer SHALL be indistinguishable from other credits after receipt. They SHALL have no promotional bucket, expiration, spending priority, refund protection, or restrictions based on their welcome origin. Existing user-level restrictions SHALL continue to apply regardless of credit origin. + +#### Scenario: Use welcome credits +- **WHEN** a profile receives welcome credits +- **THEN** subsequent spending, transfers, sponsorship eligibility, and profile merging use the same balance behavior as credits from any other source + +#### Scenario: Sponsored billing precedence +- **WHEN** a profile with welcome credits is sponsored +- **THEN** the existing sponsorship billing precedence remains in effect and the receiver's balance remains untouched while the sponsorship is active + +### Requirement: Transfer provenance +The welcome allocation SHALL appear in credit history as a credit transfer of the configured amount from `THE_AGENT` to the recipient with the note `"Welcome"`. In the same transaction, issuance SHALL first add the amount to `THE_AGENT` without a separate history record and then execute the ordinary transfer, leaving `THE_AGENT` with the same final balance. + +#### Scenario: Successful welcome transfer history +- **WHEN** an eligible EULA acceptance commits +- **THEN** credit history contains one transfer from `THE_AGENT` to the profile for the configured amount with the note `"Welcome"` + +#### Scenario: System agent balance +- **WHEN** a welcome transfer succeeds +- **THEN** `THE_AGENT` has no net balance change + +### Requirement: Atomic acceptance and issuance +The system SHALL commit the EULA transition, waitlist activation changes, temporary `THE_AGENT` balance increase, ordinary transfer balance movement, and transfer-history record in one locked database transaction. Failure of any database operation SHALL leave all of those values unchanged. + +#### Scenario: Transfer recording fails +- **WHEN** transfer-history persistence fails during an otherwise eligible EULA acceptance +- **THEN** the EULA state, activation flags, recipient balance, and transfer history are all rolled back, and `THE_AGENT` remains unchanged + +#### Scenario: Ineligible acceptance +- **WHEN** a profile accepts the EULA outside the eligibility window +- **THEN** EULA and activation changes commit without changing either balance or creating a welcome transfer record diff --git a/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/tasks.md b/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/tasks.md new file mode 100644 index 00000000..ca7282e8 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-grant-welcome-credits-on-eula/tasks.md @@ -0,0 +1,38 @@ +## 1. Configuration + +- [x] 1.1 Add `welcome_credit_grant_amount: float` (default `500.0`) and `welcome_credit_grant_eligibility_days: int` (default `7`) fields and env-var bindings to `util/config.py`, following the existing pattern +- [x] 1.2 Update `test/util/test_config.py` to cover both new fields with default and override cases + +## 2. Caller-owned transaction support + +- [x] 2.1 Add an ordered `UserRepository.get_locked_pair()` read, retain opt-in commit deferral on locked updates, and use the existing `UserRepository.save(commit=False)` path for the staged settings mutation +- [x] 2.2 Extend `UsageRecordRepository.create()` with opt-in commit deferral while preserving `commit=True` for existing callers +- [x] 2.3 Update `test/features/users/test_user_repo.py` and `test/features/accounting/usage/test_usage_record_repo.py` to verify deferred writes remain uncommitted until the caller commits and roll back with the caller transaction + +## 3. Generic credit-grant operation + +- [x] 3.1 Add `CreditTransferService.grant_credits(recipient, amount, commit, note=None)` accepting a persisted `User` or UUID without welcome-specific configuration or eligibility assumptions and requiring explicit transaction ownership +- [x] 3.2 Lock `THE_AGENT` and the recipient in UUID order, top up `THE_AGENT` by the caller-provided amount, execute the ordinary sender-minus/recipient-plus balance movement, stage transfer history, and commit only when explicitly requested +- [x] 3.3 Leave `THE_AGENT` with no net balance change or top-up history record while representing it as sender, payer, and owner in the ordinary transfer-history record +- [x] 3.4 Record the caller-provided amount and optional note with `uses_credits=True` and zero external and maintenance costs +- [x] 3.5 Keep the public peer-transfer path and its sponsored-user, handle-resolution, notification, and balance-validation behavior unchanged + +## 4. EULA acceptance integration + +- [x] 4.1 Modify `SettingsController.save_user_settings()` to read `THE_AGENT` and the recipient under ordered locks, validate activation before mutation, and derive welcome eligibility from the explicit locked-pre-update to updated EULA transition +- [x] 4.2 Inside the locked update, validate waitlist activation, apply the complete settings payload, and clear waitlist/invitation flags when activation is permitted +- [x] 4.3 For eligible acceptance, invoke `grant_credits` with the configured welcome amount, note `"Welcome"`, and `commit=False`, then commit the settings transaction unconditionally after the optional grant +- [x] 4.4 Reject omitted or `null` policy acceptance for unaccepted profiles, allow omission for already accepted profiles, and send the best-effort grant notification only after the caller-owned commit + +## 5. Behavioral tests + +- [x] 5.1 Update `test/features/accounting/transfers/test_credit_transfer_service.py` to cover `User` and UUID recipients, caller-provided amounts, optional notes, ordered agent/recipient locking, net-zero agent balance, explicit committed and deferred paths, rollback, notification timing, and transfer-record fields +- [x] 5.2 Update `test/api/test_settings_controller.py` to cover required first acceptance, ordinary post-acceptance settings saves, first acceptance, inclusive eligibility boundary, repeated acceptance, a stale pre-lock authorization snapshot, acceptance outside the window, and waitlist activation +- [x] 5.3 Cover persisted successful issuance and repeated-acceptance idempotency through the settings layer +- [x] 5.4 Keep the rollback test proving transfer-record failure preserves the prior EULA state, activation flags, balances, and transfer history + +## 6. Documentation and verification + +- [x] 6.1 Update the EULA-acceptance description in `docs/open-api-docs.yaml` to document the conditional one-time welcome transfer +- [x] 6.2 Run the focused configuration, repository, transfer, settings-controller, and profile-connect tests through `pipenv` +- [x] 6.3 Run Ruff and the project spacing checker on every changed Python file diff --git a/pyproject.toml b/pyproject.toml index 109032a8..0c886d27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "the-agent" -version = "5.32.3" +version = "5.33.0" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/api/settings_controller.py b/src/api/settings_controller.py index d9c2d5de..2e737629 100644 --- a/src/api/settings_controller.py +++ b/src/api/settings_controller.py @@ -1,5 +1,5 @@ from dataclasses import replace -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from typing import Annotated, Literal, TypeAlias, get_args from uuid import UUID @@ -22,6 +22,7 @@ from features.external_tools.external_tool_library import ALL_EXTERNAL_TOOLS from features.external_tools.external_tool_provider_library import ALL_PROVIDERS from features.external_tools.intelligence_presets import get_all_presets +from features.integrations.integration_config import THE_AGENT from features.integrations.integrations import is_own_chat, resolve_agent_user, resolve_external_handle, resolve_external_id from features.users.user import User from util import log @@ -36,9 +37,10 @@ INVALID_TOOL_CHOICE, MISSING_CHAT_CONTEXT, NO_PRIVATE_CHAT, - POLICY_ACCEPTANCE_REVOCATION_FORBIDDEN, + POLICY_ACCEPTANCE_REQUIRED, + USER_UPDATE_FAILED, ) -from util.errors import AuthorizationError, ConfigurationError, ValidationError +from util.errors import AuthorizationError, ConfigurationError, InternalError, ServiceError, ValidationError SettingsType: TypeAlias = Annotated[str, Literal["user", "chat", "intelligence"]] InvokerType: TypeAlias = Annotated[str, Literal["creator", "administrator"]] @@ -290,30 +292,67 @@ def save_user_settings(self, user_id_hex: str, payload: UserSettingsPayload): log.d(f"Saving user settings for user '{user_id_hex}'") user = self.__di.authorization_service.authorize_for_user(self.__di.invoker, user_id_hex) - # validate tool choices + # validate the given tool choices all_tool_ids = {tool.id for tool in ALL_EXTERNAL_TOOLS} for key, value in payload.model_dump().items(): if key.startswith("tool_choice_") and value and (value not in all_tool_ids): raise ValidationError(f"Invalid tool choice '{value}' for '{key}'. Tool is not recognized.", INVALID_TOOL_CHOICE) - if payload.are_policies_accepted is False: - raise ValidationError( - "Policy acceptance cannot be revoked once accepted", - POLICY_ACCEPTANCE_REVOCATION_FORBIDDEN, - ) - should_activate_waitlisted_user = ( - payload.are_policies_accepted is True and - user.is_on_waitlist - ) - if should_activate_waitlisted_user: - self.__di.authorization_service.require_waitlisted_user_can_activate(user) + # policies must be accepted before any settings changes (and cannot be revoked) + if ( + (user.are_policies_accepted is False and payload.are_policies_accepted is None) + or payload.are_policies_accepted is False + ): + raise ValidationError("Policy acceptance is required before updating settings and cannot be revoked", POLICY_ACCEPTANCE_REQUIRED) # ruff: ignore[line-too-long] + + if payload.are_policies_accepted is None: + updated_user = apply_to_domain(payload, user) + self.__di.user_repo.save(updated_user) + log.i("User settings saved") + return + + try: + # now the policies are set to true (maybe a change, maybe as-as from before) + _, locked_user = self.__di.user_repo.get_locked_pair(THE_AGENT.id, user.id) + should_activate_waitlisted_user = (locked_user.are_policies_accepted is False) and locked_user.is_on_waitlist + if should_activate_waitlisted_user: + self.__di.authorization_service.require_waitlisted_user_can_activate(locked_user) + + updated_user = apply_to_domain(payload, locked_user) + if should_activate_waitlisted_user: + updated_user = replace(updated_user, is_on_waitlist = False, is_invited_to_start = False) + updated_user = self.__di.user_repo.save(updated_user, commit = False) + + should_grant_welcome_credits = self.__should_grant_welcome_credits(locked_user, updated_user) + if should_grant_welcome_credits: + updated_user = self.__di.credit_transfer_service.grant_credits( + recipient = updated_user, + amount = config.welcome_credit_grant_amount, + note = "Welcome", + commit = False, + ) + self.__di.db.commit() + + if should_grant_welcome_credits: + self.__di.credit_transfer_service.notify_grant(updated_user, config.welcome_credit_grant_amount, "Welcome") + except ServiceError: + self.__di.db.rollback() + raise + except Exception as e: + self.__di.db.rollback() + raise InternalError(f"Failed to save user settings for '{user_id_hex}': {e}", USER_UPDATE_FAILED) from e - updated_user = apply_to_domain(payload, user) - if should_activate_waitlisted_user: - updated_user = replace(updated_user, is_on_waitlist = False, is_invited_to_start = False) - self.__di.user_repo.save(updated_user) log.i("User settings saved") + @staticmethod + def __should_grant_welcome_credits(current_user: User, updated_user: User) -> bool: + return ( + current_user.are_policies_accepted is False + and updated_user.are_policies_accepted is True + and current_user.created_at is not None + and (date.today() - current_user.created_at).days <= config.welcome_credit_grant_eligibility_days + ) + def __is_sponsored(self, user_id: UUID) -> bool: return bool(self.__di.sponsorship_repo.get_all_by_receiver(user_id)) diff --git a/src/features/accounting/transfers/credit_transfer_service.py b/src/features/accounting/transfers/credit_transfer_service.py index 9e00879c..f2bf9bf8 100644 --- a/src/features/accounting/transfers/credit_transfer_service.py +++ b/src/features/accounting/transfers/credit_transfer_service.py @@ -10,6 +10,7 @@ from features.external_tools.external_tool import ToolType from features.external_tools.external_tool_library import TRANSFER_TOOL from features.external_tools.intelligence_presets import default_tool_for +from features.integrations.integration_config import THE_AGENT from features.integrations.integrations import ( format_handle, lookup_user_by_handle, @@ -76,20 +77,89 @@ def apply_transfer(sender_user: User, receiver_user: User) -> tuple[User, User]: raise InternalError(f"Credit transfer failed: {e}", TRANSFER_FAILED) from e # update usage records to reflect this transfer - self.__create_usage_records(sender_user, receiver_user, amount, note, started_at) + self.__create_transfer_record(sender_user, receiver_user, amount, note, started_at) log.i(f"Transfer completed: {sender_user.id} -> {receiver_user.id}, amount = {amount}") # and finally notify the participants self.__try_to_notify_sender(sender_user, recipient_handle, chat_type, amount) self.__try_to_notify_receiver(receiver_user, sender_user, chat_type, amount, note) - def __create_usage_records( + def grant_credits( + self, + recipient: User | UUID, + amount: float, + commit: bool, + note: str | None = None, + ) -> User: + recipient_id = recipient if isinstance(recipient, UUID) else recipient.id + if recipient_id is None: + raise NotFoundError("Grant recipient has not been persisted", USER_NOT_FOUND) + + started_at = datetime.now(timezone.utc) + log.d(f"Granting {amount} credits to {recipient_id}...") + + def mint_agent_credits(agent_user: User, recipient_user: User) -> tuple[User, User]: + return replace(agent_user, credit_balance = agent_user.credit_balance + amount), recipient_user + + def apply_transfer(agent_user: User, recipient_user: User) -> tuple[User, User]: + return ( + replace(agent_user, credit_balance = agent_user.credit_balance - amount), + replace(recipient_user, credit_balance = recipient_user.credit_balance + amount), + ) + + try: + # first we mint the credits for The Agent's account + self.__di.user_repo.update_locked_pair( + first_id = THE_AGENT.id, + second_id = recipient_id, + update_fn = mint_agent_credits, + commit = False, + ) + # then we transfer them to the granted user + updated_agent, updated_recipient = self.__di.user_repo.update_locked_pair( + first_id = THE_AGENT.id, + second_id = recipient_id, + update_fn = apply_transfer, + commit = False, + ) + # and finally, keep a record of the grant for bookkeeping purposes + self.__create_transfer_record( + sender_user = updated_agent, + receiver_user = updated_recipient, + amount = amount, + note = note, + started_at = started_at, + commit = False, + ) + if commit: + self.__di.db.commit() + except ServiceError: + self.__di.db.rollback() + raise + except Exception as e: + self.__di.db.rollback() + raise InternalError(f"Credit grant failed for recipient {recipient_id}: {e}", TRANSFER_FAILED) from e + + if commit: + self.notify_grant(updated_recipient, amount, note) + return updated_recipient + + def notify_grant(self, recipient: User, amount: float, note: str | None = None): + log.i(f"Granted {amount} credits to {recipient.id}") + message = f"You have been granted {amount} credits" + if note: + message += f" for \"{note}\"" + message += ". Enjoy!" + self.__try_to_send_notification(recipient, message) + + def __create_transfer_record( self, sender_user: User, receiver_user: User, amount: float, note: str | None, started_at: datetime, + commit: bool = True, ): sender_info = user_to_participant(sender_user) receiver_info = user_to_participant(receiver_user) @@ -119,7 +189,7 @@ def __create_usage_records( counterpart = receiver_info, ), ) - self.__di.usage_record_repo.create(record) + self.__di.usage_record_repo.create(record, commit = commit) def __validate_transfer( self, diff --git a/src/features/accounting/usage/usage_record_repo.py b/src/features/accounting/usage/usage_record_repo.py index bb841f7e..8b39bc45 100644 --- a/src/features/accounting/usage/usage_record_repo.py +++ b/src/features/accounting/usage/usage_record_repo.py @@ -22,10 +22,14 @@ def get(self, record_id: UUID) -> UsageRecord | None: db_model = self._db.query(UsageRecordDB).filter(UsageRecordDB.id == record_id).first() return domain(db_model) - def create(self, record: UsageRecord) -> UsageRecord: + def create(self, record: UsageRecord, commit: bool = True) -> UsageRecord: db_model = db(record) self._db.add(db_model) - self._db.commit() + + self._db.flush() + if commit: + self._db.commit() + self._db.refresh(db_model) return domain(db_model) diff --git a/src/features/users/user_repo.py b/src/features/users/user_repo.py index 2bc31a38..ecdc5212 100644 --- a/src/features/users/user_repo.py +++ b/src/features/users/user_repo.py @@ -78,6 +78,10 @@ def get_by_remote_data(self, remote_data: UserRemoteData) -> User | None: return self.get_by_whatsapp_phone_number(remote_data.whatsapp_phone_number.get_secret_value()) return None + def get_locked_pair(self, first_id: UUID, second_id: UUID) -> tuple[User, User]: + first, second = self.__get_locked_db_pair(first_id, second_id) + return domain(first), domain(second) + def save(self, user: User, commit: bool = True) -> User: existing: UserDB | None = None if user.id is not None: @@ -95,13 +99,15 @@ def save(self, user: User, commit: bool = True) -> User: db_model = db(user) self._db.add(db_model) + self._db.flush() if commit: self._db.commit() self._db.refresh(db_model) + return domain(db_model) - def update_locked(self, user_id: UUID, update_fn: Callable[[User], User]) -> User: + def update_locked(self, user_id: UUID, update_fn: Callable[[User], User], commit: bool = True) -> User: db_model = self._db.query(UserDB).filter( UserDB.id == user_id, ).with_for_update().first() @@ -110,7 +116,11 @@ def update_locked(self, user_id: UUID, update_fn: Callable[[User], User]) -> Use updated = update_fn(domain(db_model)) apply_to_db_model(updated, db_model) - self._db.commit() + + self._db.flush() + if commit: + self._db.commit() + self._db.refresh(db_model) return domain(db_model) @@ -119,7 +129,22 @@ def update_locked_pair( first_id: UUID, second_id: UUID, update_fn: Callable[[User, User], tuple[User, User]], + commit: bool = True, ) -> tuple[User, User]: + first, second = self.__get_locked_db_pair(first_id, second_id) + updated_first, updated_second = update_fn(domain(first), domain(second)) + apply_to_db_model(updated_first, first) + apply_to_db_model(updated_second, second) + + self._db.flush() + if commit: + self._db.commit() + + self._db.refresh(first) + self._db.refresh(second) + return domain(first), domain(second) + + def __get_locked_db_pair(self, first_id: UUID, second_id: UUID) -> tuple[UserDB, UserDB]: # rows are locked in UUID order to avoid deadlocks lock_order = sorted([first_id, second_id]) @@ -133,18 +158,10 @@ def update_locked_pair( if first is None or second is None: raise NotFoundError("User not found", USER_NOT_FOUND) - # locks might have come out of order, but callbacks receive caller order; let's check which is which + # locks might have come out of order, but callbacks need the original order; let's check which is which mapped_first = first if first.id == first_id else second mapped_second = second if second.id == second_id else first - - updated_first, updated_second = update_fn(domain(mapped_first), domain(mapped_second)) - apply_to_db_model(updated_first, mapped_first) - apply_to_db_model(updated_second, mapped_second) - - self._db.commit() - self._db.refresh(mapped_first) - self._db.refresh(mapped_second) - return domain(mapped_first), domain(mapped_second) + return mapped_first, mapped_second def delete(self, user_id: UUID, commit: bool = True) -> User | None: db_model = self._db.query(UserDB).filter( @@ -154,6 +171,7 @@ def delete(self, user_id: UUID, commit: bool = True) -> User | None: return None snapshot = domain(db_model) self._db.delete(db_model) + self._db.flush() if commit: self._db.commit() diff --git a/src/util/config.py b/src/util/config.py index d426f5c6..15b14169 100644 --- a/src/util/config.py +++ b/src/util/config.py @@ -69,6 +69,8 @@ class Config(metaclass = Singleton): url_shortener_base_url: str version: str usage_maintenance_fee_credits: float + welcome_credit_grant_amount: float + welcome_credit_grant_eligibility_days: int products_config_path: str products: dict[str, ConfiguredProduct] logos_config_path: str @@ -182,6 +184,8 @@ def __init__( def_url_shortener_base_url: str = "https://urls.appifyhub.com", def_version: str = "dev", def_usage_maintenance_fee_credits: float = 0.0, + def_welcome_credit_grant_amount: float = 500.0, + def_welcome_credit_grant_eligibility_days: int = 7, def_products_config_path: str = "config/products.yaml", def_logos_config_path: str = "config/logos.yaml", def_fonts_dir: str = "src/assets/fonts", @@ -264,6 +268,8 @@ def __init__( self.url_shortener_base_url = self.__env("URL_SHORTENER_BASE_URL", lambda: def_url_shortener_base_url) self.version = self.__env("VERSION", lambda: def_version) self.usage_maintenance_fee_credits = float(self.__env("USAGE_MAINTENANCE_FEE_CREDITS", lambda: str(def_usage_maintenance_fee_credits))) + self.welcome_credit_grant_amount = float(self.__env("WELCOME_CREDIT_GRANT_AMOUNT", lambda: str(def_welcome_credit_grant_amount))) + self.welcome_credit_grant_eligibility_days = int(self.__env("WELCOME_CREDIT_GRANT_ELIGIBILITY_DAYS", lambda: str(def_welcome_credit_grant_eligibility_days))) self.products_config_path = self.__env("PRODUCTS_CONFIG_PATH", lambda: def_products_config_path) self.products = self.__load_products() self.logos_config_path = self.__env("LOGOS_CONFIG_PATH", lambda: def_logos_config_path) diff --git a/src/util/error_codes.py b/src/util/error_codes.py index c1d46858..68e9c352 100644 --- a/src/util/error_codes.py +++ b/src/util/error_codes.py @@ -37,7 +37,7 @@ UNSUPPORTED_CURRENCY_PAIR = 1028 MALFORMED_USER_ID = 1029 MALFORMED_CHAT_ID = 1030 -POLICY_ACCEPTANCE_REVOCATION_FORBIDDEN = 1031 +POLICY_ACCEPTANCE_REQUIRED = 1031 INVALID_TRANSFER_AMOUNT = 1032 SELF_TRANSFER_NOT_ALLOWED = 1033 INSUFFICIENT_CREDITS = 1034 diff --git a/test/api/test_settings_controller.py b/test/api/test_settings_controller.py index 259f11c8..db1d171c 100644 --- a/test/api/test_settings_controller.py +++ b/test/api/test_settings_controller.py @@ -2,10 +2,11 @@ import json import unittest from dataclasses import replace -from datetime import datetime +from datetime import date, datetime, timedelta from unittest.mock import MagicMock, PropertyMock, patch from uuid import UUID +from db.sql_util import SQLUtil from pydantic import SecretStr from api.authorization_service import AuthorizationService @@ -34,16 +35,17 @@ TWELVE_DATA_STOCK_QUOTE, VIDEO_GEN_P_VIDEO, ) +from features.integrations.integration_config import THE_AGENT from features.sponsorships.sponsorship_repo import SponsorshipRepository from features.users.user import User from features.users.user_repo import UserRepository -from util.config import ConfiguredProduct +from util.config import ConfiguredProduct, config from util.error_codes import ( NOT_CHAT_ADMIN, - POLICY_ACCEPTANCE_REVOCATION_FORBIDDEN, + POLICY_ACCEPTANCE_REQUIRED, WAITLIST_ACCOUNT_NOT_ACTIVE, ) -from util.errors import AuthorizationError, ValidationError +from util.errors import AuthorizationError, InternalError, ValidationError from util.functions import mask_secret @@ -92,6 +94,7 @@ def setUp(self): tool_choice_api_stock_quote = TWELVE_DATA_STOCK_QUOTE.id, group = UserDB.Group.developer, created_at = datetime.now().date(), + are_policies_accepted = True, ) self.chat_config = ChatConfigDomain( chat_id = UUID(int = 1), @@ -180,6 +183,31 @@ def mock_url_shortener(long_url, **kwargs): return mock_shortener self.mock_di.url_shortener = MagicMock(side_effect = mock_url_shortener) + # Mock locked settings read, persistence, and generic credit grant + self.applied_settings_user: User | None = None + + def _get_locked_pair_side_effect(first_id, second_id): + return THE_AGENT, self.mock_authorization_service.authorize_for_user.return_value + + self.mock_user_repo.get_locked_pair.side_effect = _get_locked_pair_side_effect + + def _save_user_side_effect(user, commit = True): + self.applied_settings_user = user + return user + + self.mock_user_repo.save.side_effect = _save_user_side_effect + + def _grant_credits_side_effect(recipient, amount, note = None, *, commit): + self.assertFalse(commit) + updated = replace(recipient, credit_balance = recipient.credit_balance + amount) + self.applied_settings_user = updated + return updated + + self.mock_credit_transfer_service = MagicMock() + self.mock_credit_transfer_service.grant_credits.side_effect = _grant_credits_side_effect + # noinspection PyPropertyAccess + self.mock_di.credit_transfer_service = self.mock_credit_transfer_service + @staticmethod def create_admin_member(telegram_user, is_manager = True): return ChatMemberAdministrator( @@ -337,7 +365,19 @@ def test_save_user_settings_reject_policy_false(self): with self.assertRaises(ValidationError) as context: controller.save_user_settings(self.invoker_user.id.hex, payload) - self.assertEqual(context.exception.error_code, POLICY_ACCEPTANCE_REVOCATION_FORBIDDEN) + self.assertEqual(context.exception.error_code, POLICY_ACCEPTANCE_REQUIRED) + + def test_save_user_settings_requires_policy_acceptance_before_other_changes(self): + unaccepted_user = replace(self.invoker_user, are_policies_accepted = False) + self.mock_authorization_service.authorize_for_user.return_value = unaccepted_user + + controller = SettingsController(self.mock_di) + payload = UserSettingsPayload(full_name = "New Name") + with self.assertRaises(ValidationError) as context: + controller.save_user_settings(unaccepted_user.id.hex, payload) + + self.assertEqual(context.exception.error_code, POLICY_ACCEPTANCE_REQUIRED) + self.mock_user_repo.save.assert_not_called() def test_save_user_settings_waitlisted_activation_when_capacity_available(self): waitlisted_user = replace( @@ -354,7 +394,8 @@ def test_save_user_settings_waitlisted_activation_when_capacity_available(self): controller.save_user_settings(waitlisted_user.id.hex, payload) self.mock_authorization_service.require_waitlisted_user_can_activate.assert_called_once_with(waitlisted_user) - saved_payload = self.mock_user_repo.save.call_args.args[0] + self.mock_credit_transfer_service.grant_credits.assert_called_once() + saved_payload = self.applied_settings_user self.assertFalse(saved_payload.is_on_waitlist) self.assertFalse(saved_payload.is_invited_to_start) self.assertTrue(saved_payload.are_policies_accepted) @@ -379,6 +420,181 @@ def test_save_user_settings_waitlisted_activation_denied_without_invite_or_capac controller.save_user_settings(waitlisted_user.id.hex, payload) self.assertEqual(context.exception.error_code, WAITLIST_ACCOUNT_NOT_ACTIVE) + self.mock_user_repo.save.assert_not_called() + self.mock_credit_transfer_service.grant_credits.assert_not_called() + self.mock_di.db.rollback.assert_called_once() + + def test_save_user_settings_accepts_eula_first_time_grants_welcome_credits(self): + unaccepted_user = replace(self.invoker_user, are_policies_accepted = False) + self.mock_authorization_service.authorize_for_user.return_value = unaccepted_user + + controller = SettingsController(self.mock_di) + payload = UserSettingsPayload(are_policies_accepted = True) + controller.save_user_settings(unaccepted_user.id.hex, payload) + + expected_recipient = replace(unaccepted_user, are_policies_accepted = True) + self.mock_credit_transfer_service.grant_credits.assert_called_once_with( + recipient = expected_recipient, + amount = config.welcome_credit_grant_amount, + note = "Welcome", + commit = False, + ) + self.mock_user_repo.get_locked_pair.assert_called_once_with(THE_AGENT.id, unaccepted_user.id) + self.mock_user_repo.save.assert_called_once_with(expected_recipient, commit = False) + self.mock_di.db.commit.assert_called_once() + self.mock_credit_transfer_service.notify_grant.assert_called_once_with( + replace(expected_recipient, credit_balance = config.welcome_credit_grant_amount), + config.welcome_credit_grant_amount, + "Welcome", + ) + + def test_save_user_settings_acceptance_at_eligibility_boundary_grants_welcome_credits(self): + unaccepted_user = replace( + self.invoker_user, + are_policies_accepted = False, + created_at = date.today() - timedelta(days = config.welcome_credit_grant_eligibility_days), + ) + self.mock_authorization_service.authorize_for_user.return_value = unaccepted_user + + controller = SettingsController(self.mock_di) + payload = UserSettingsPayload(are_policies_accepted = True) + controller.save_user_settings(unaccepted_user.id.hex, payload) + + self.mock_credit_transfer_service.grant_credits.assert_called_once() + self.mock_di.db.commit.assert_called_once() + + def test_save_user_settings_repeated_acceptance_no_additional_grant(self): + already_accepted_user = replace(self.invoker_user, are_policies_accepted = True) + self.mock_authorization_service.authorize_for_user.return_value = already_accepted_user + + controller = SettingsController(self.mock_di) + payload = UserSettingsPayload(are_policies_accepted = True) + controller.save_user_settings(already_accepted_user.id.hex, payload) + + self.mock_credit_transfer_service.grant_credits.assert_not_called() + self.mock_di.db.commit.assert_called_once() + + def test_save_user_settings_uses_locked_eula_state_for_grant_eligibility(self): + stale_user = replace(self.invoker_user, are_policies_accepted = False) + persisted_user = replace(stale_user, are_policies_accepted = True) + self.mock_authorization_service.authorize_for_user.return_value = stale_user + self.mock_user_repo.get_locked_pair.side_effect = None + self.mock_user_repo.get_locked_pair.return_value = THE_AGENT, persisted_user + + controller = SettingsController(self.mock_di) + payload = UserSettingsPayload(are_policies_accepted = True) + controller.save_user_settings(stale_user.id.hex, payload) + + self.mock_credit_transfer_service.grant_credits.assert_not_called() + self.mock_di.db.commit.assert_called_once() + + def test_save_user_settings_acceptance_outside_window_no_grant(self): + unaccepted_user = replace( + self.invoker_user, + are_policies_accepted = False, + created_at = date.today() - timedelta(days = config.welcome_credit_grant_eligibility_days + 1), + ) + self.mock_authorization_service.authorize_for_user.return_value = unaccepted_user + + controller = SettingsController(self.mock_di) + payload = UserSettingsPayload(are_policies_accepted = True) + controller.save_user_settings(unaccepted_user.id.hex, payload) + + self.mock_credit_transfer_service.grant_credits.assert_not_called() + self.mock_di.db.commit.assert_called_once() + + def test_save_user_settings_without_eula_acceptance_does_not_invoke_grant(self): + controller = SettingsController(self.mock_di) + payload = UserSettingsPayload(full_name = "New Name") + controller.save_user_settings(self.invoker_user.id.hex, payload) + + self.mock_credit_transfer_service.grant_credits.assert_not_called() + self.mock_di.db.commit.assert_not_called() + self.mock_user_repo.save.assert_called_once() + + def test_save_user_settings_eula_acceptance_persists_welcome_grant(self): + sql = SQLUtil() + self.addCleanup(sql.end_session) + db = sql.get_session() + + agent_user = sql.user_repo().save(replace(THE_AGENT, credit_balance = 0.0)) + recipient = sql.user_repo().save( + User( + full_name = "New User", + telegram_username = "new_user", + telegram_user_id = 555, + telegram_chat_id = "555", + group = UserDB.Group.standard, + created_at = date.today(), + credit_balance = 0.0, + are_policies_accepted = False, + ), + ) + + controller = SettingsController(DI(db = db, invoker_id = recipient.id.hex)) + controller.save_user_settings( + recipient.id.hex, + UserSettingsPayload(are_policies_accepted = True), + ) + + reloaded_recipient = sql.user_repo().get(recipient.id) + reloaded_agent = sql.user_repo().get(agent_user.id) + records = sql.usage_record_repo().get_by_user(agent_user.id) + self.assertTrue(reloaded_recipient.are_policies_accepted) + self.assertEqual(reloaded_recipient.credit_balance, config.welcome_credit_grant_amount) + self.assertEqual(reloaded_agent.credit_balance, 0.0) + self.assertEqual(len(records), 1) + self.assertEqual(records[0].total_cost_credits, config.welcome_credit_grant_amount) + self.assertEqual(records[0].note, "Welcome") + + def test_save_user_settings_eula_acceptance_rolls_back_on_persistence_failure(self): + sql = SQLUtil() + self.addCleanup(sql.end_session) + db = sql.get_session() + + agent_user = sql.user_repo().save(replace(THE_AGENT, credit_balance = 0.0)) + recipient = sql.user_repo().save( + User( + full_name = "New User", + telegram_username = "new_user", + telegram_user_id = 555, + telegram_chat_id = "555", + group = UserDB.Group.standard, + created_at = date.today(), + credit_balance = 0.0, + are_policies_accepted = False, + ), + ) + + class _FailingUsageRecordRepo: + + def __init__(self, real_repo): + self.__real = real_repo + + def create(self, record, commit = True): + raise RuntimeError("simulated persistence failure") + + def __getattr__(self, name): + return getattr(self.__real, name) + + di = DI(db = db, invoker_id = recipient.id.hex) + real_usage_record_repo = di.usage_record_repo + # noinspection PyProtectedMember + di._usage_record_repo = _FailingUsageRecordRepo(real_usage_record_repo) + + controller = SettingsController(di) + payload = UserSettingsPayload(are_policies_accepted = True) + + with self.assertRaises(InternalError): + controller.save_user_settings(recipient.id.hex, payload) + + db.rollback() + reloaded_recipient = sql.user_repo().get(recipient.id) + reloaded_agent = sql.user_repo().get(agent_user.id) + self.assertFalse(reloaded_recipient.are_policies_accepted) + self.assertEqual(reloaded_recipient.credit_balance, 0.0) + self.assertEqual(reloaded_agent.credit_balance, 0.0) + self.assertEqual(len(sql.usage_record_repo().get_by_user(agent_user.id)), 0) def test_save_chat_settings_failure_language_mismatch(self): controller = SettingsController(self.mock_di) diff --git a/test/features/accounting/transfers/test_credit_transfer_service.py b/test/features/accounting/transfers/test_credit_transfer_service.py index 7b8f7329..57afb706 100644 --- a/test/features/accounting/transfers/test_credit_transfer_service.py +++ b/test/features/accounting/transfers/test_credit_transfer_service.py @@ -1,6 +1,7 @@ import unittest +from dataclasses import replace from datetime import date -from unittest.mock import Mock +from unittest.mock import Mock, patch from uuid import UUID from db.model.chat_config import ChatConfigDB @@ -9,16 +10,18 @@ from features.accounting.transfers.credit_transfer_service import CreditTransferService from features.external_tools.external_tool import ToolType from features.external_tools.external_tool_library import TRANSFER_TOOL +from features.integrations.integration_config import THE_AGENT from features.users.user import User from util.error_codes import ( INSUFFICIENT_CREDITS, INVALID_TRANSFER_AMOUNT, SELF_TRANSFER_NOT_ALLOWED, SPONSORED_USER_TRANSFER_NOT_ALLOWED, + TRANSFER_FAILED, TRANSFER_RECIPIENT_NOT_FOUND, USER_NOT_FOUND, ) -from util.errors import NotFoundError, ValidationError +from util.errors import InternalError, NotFoundError, ValidationError def _make_user(user_id: int, handle: str, credit_balance: float = 100.0) -> User: @@ -246,3 +249,169 @@ def test_transfer_calls_db_lock_with_correct_ids(self): self.assertEqual(args[0], self.sender.id) self.assertEqual(args[1], self.receiver.id) self.assertTrue(callable(args[2])) + + def _make_recipient(self, credit_balance: float = 0.0) -> User: + return User( + id = UUID(int = 99), + full_name = "Recipient", + telegram_username = "recipient_handle", + telegram_user_id = 99, + telegram_chat_id = "99", + group = UserDB.Group.standard, + created_at = date.today(), + credit_balance = credit_balance, + ) + + def _fake_update_locked_for_grant(self, recipient: User): + self.grant_agent = replace(THE_AGENT, credit_balance = 0.0) + self.grant_pair_results: list[tuple[User, User]] = [] + users = { + self.grant_agent.id: self.grant_agent, + recipient.id: recipient, + } + + def fake(first_id, second_id, update_fn, commit = True): + result = update_fn(users[first_id], users[second_id]) + users[first_id], users[second_id] = result + self.grant_pair_results.append(result) + return result + + self.mock_di.user_repo.update_locked_pair.side_effect = fake + + def test_credit_grant_accepts_recipient_id(self): + recipient = self._make_recipient() + self._fake_update_locked_for_grant(recipient) + + updated = self.service.grant_credits( + recipient = recipient.id, + amount = 125.0, + commit = True, + ) + + self.assertEqual(updated.credit_balance, 125.0) + + def test_credit_grant_accepts_recipient_user_and_preserves_existing_balance(self): + recipient = self._make_recipient(credit_balance = 250.0) + self._fake_update_locked_for_grant(recipient) + + updated = self.service.grant_credits( + recipient = recipient, + amount = 75.0, + commit = True, + ) + + self.assertEqual(updated.credit_balance, 325.0) + + def test_credit_grant_funds_agent_then_transfers_and_commits(self): + recipient = self._make_recipient() + self._fake_update_locked_for_grant(recipient) + + self.service.grant_credits( + recipient = recipient, + amount = 50.0, + commit = True, + ) + + calls = self.mock_di.user_repo.update_locked_pair.call_args_list + self.assertEqual(len(calls), 2) + for locked_pair_call in calls: + if locked_pair_call.args: + first_id, second_id, update_fn = locked_pair_call.args + else: + first_id = locked_pair_call.kwargs["first_id"] + second_id = locked_pair_call.kwargs["second_id"] + update_fn = locked_pair_call.kwargs["update_fn"] + self.assertEqual(first_id, THE_AGENT.id) + self.assertEqual(second_id, recipient.id) + self.assertTrue(callable(update_fn)) + self.assertFalse(locked_pair_call.kwargs["commit"]) + self.assertEqual(self.grant_pair_results[0][0].credit_balance, 50.0) + self.assertEqual(self.grant_pair_results[1][0].credit_balance, self.grant_agent.credit_balance) + self.mock_di.db.commit.assert_called_once() + self.mock_di.db.rollback.assert_not_called() + + def test_credit_grant_defers_commit_and_notification_when_requested(self): + recipient = self._make_recipient() + self._fake_update_locked_for_grant(recipient) + + with patch.object(self.service, "notify_grant") as notify: + updated = self.service.grant_credits( + recipient = recipient, + amount = 50.0, + commit = False, + ) + + self.assertEqual(updated.credit_balance, 50.0) + self.mock_di.db.commit.assert_not_called() + notify.assert_not_called() + + def test_credit_grant_creates_transfer_history_record(self): + recipient = self._make_recipient() + self._fake_update_locked_for_grant(recipient) + + with patch.object(self.service, "_CreditTransferService__try_to_send_notification") as notify: + self.service.grant_credits( + recipient = recipient, + amount = 500.0, + note = "Welcome", + commit = True, + ) + + record = self._get_created_record() + self.assertEqual(record.user_id, THE_AGENT.id) + self.assertEqual(record.payer_id, THE_AGENT.id) + self.assertEqual(record.counterpart_id, recipient.id) + self.assertEqual(record.note, "Welcome") + self.assertTrue(record.uses_credits) + self.assertEqual(record.total_cost_credits, 500.0) + self.mock_di.usage_record_repo.create.assert_called_once_with(record, commit = False) + notify.assert_called_once_with( + self.grant_pair_results[1][1], + "You have been granted 500.0 credits for \"Welcome\". Enjoy!", + ) + + def test_credit_grant_note_defaults_to_none(self): + recipient = self._make_recipient() + self._fake_update_locked_for_grant(recipient) + + with patch.object(self.service, "_CreditTransferService__try_to_send_notification") as notify: + self.service.grant_credits( + recipient = recipient, + amount = 25.0, + commit = True, + ) + + self.assertIsNone(self._get_created_record().note) + notify.assert_called_once_with( + self.grant_pair_results[1][1], + "You have been granted 25.0 credits. Enjoy!", + ) + + def test_credit_grant_rolls_back_when_record_creation_fails(self): + recipient = self._make_recipient() + self._fake_update_locked_for_grant(recipient) + expected = InternalError("Record creation failed", TRANSFER_FAILED) + self.mock_di.usage_record_repo.create.side_effect = expected + + with self.assertRaises(InternalError) as context: + self.service.grant_credits( + recipient = recipient, + amount = 25.0, + commit = True, + ) + + self.assertIs(context.exception, expected) + self.mock_di.db.commit.assert_not_called() + self.mock_di.db.rollback.assert_called_once() + + def test_credit_grant_rejects_unpersisted_user(self): + recipient = User(full_name = "Unpersisted") + + with self.assertRaises(NotFoundError) as context: + self.service.grant_credits( + recipient = recipient, + amount = 25.0, + commit = True, + ) + + self.assertEqual(context.exception.error_code, USER_NOT_FOUND) diff --git a/test/features/accounting/usage/test_usage_record_repo.py b/test/features/accounting/usage/test_usage_record_repo.py index 6e180a0d..9c62ae41 100644 --- a/test/features/accounting/usage/test_usage_record_repo.py +++ b/test/features/accounting/usage/test_usage_record_repo.py @@ -80,6 +80,22 @@ def test_create(self): self.assertEqual(persisted.output_video_size, "1k") self.assertEqual(persisted.output_video_duration_seconds, 5) + def test_create_defers_commit_when_requested(self): + record = self._create_record() + + self.repo.create(record, commit = False) + + self.sql.get_session().rollback() + self.assertEqual(len(self.repo.get_by_user(self.user.id)), 0) + + def test_create_deferred_commit_persists_with_caller_commit(self): + record = self._create_record() + + self.repo.create(record, commit = False) + self.sql.get_session().commit() + + self.assertEqual(len(self.repo.get_by_user(self.user.id)), 1) + def test_get(self): record = self._create_record() self.repo.create(record) diff --git a/test/features/users/test_user_repo.py b/test/features/users/test_user_repo.py index 6223483c..e6e5c87e 100644 --- a/test/features/users/test_user_repo.py +++ b/test/features/users/test_user_repo.py @@ -211,6 +211,30 @@ def test_update_locked_raises_when_missing(self): with self.assertRaises(NotFoundError): self.repo.update_locked(uuid4(), lambda user: user) + def test_update_locked_defers_commit_when_requested(self): + created = self.repo.save(self.__user(connect_key = "LOCK-DEFER-01", credit_balance = 10.0)) + + updated = self.repo.update_locked( + created.id, + lambda user: replace(user, credit_balance = 25.0), + commit = False, + ) + + self.assertEqual(updated.credit_balance, 25.0) + + self.sql.get_session().rollback() + + self.assertEqual(self.repo.get(created.id).credit_balance, 10.0) + + def test_get_locked_pair_returns_users_in_requested_order(self): + first = self.repo.save(self.__user(connect_key = "PAIR-GET-001")) + second = self.repo.save(self.__user(connect_key = "PAIR-GET-002")) + + locked_second, locked_first = self.repo.get_locked_pair(second.id, first.id) + + self.assertEqual(locked_second.id, second.id) + self.assertEqual(locked_first.id, first.id) + def test_update_locked_pair_updates_users_in_requested_order(self): first = self.repo.save(self.__user(connect_key = "PAIR-KEY-001", credit_balance = 100.0)) second = self.repo.save(self.__user(connect_key = "PAIR-KEY-002", credit_balance = 25.0)) @@ -231,6 +255,24 @@ def transfer(sender: User, receiver: User) -> tuple[User, User]: self.assertEqual(self.repo.get(second.id).credit_balance, -15.0) self.assertEqual(self.repo.get(first.id).credit_balance, 140.0) + def test_update_locked_pair_defers_commit_when_requested(self): + first = self.repo.save(self.__user(connect_key = "PAIR-DEFER-001", credit_balance = 100.0)) + second = self.repo.save(self.__user(connect_key = "PAIR-DEFER-002", credit_balance = 25.0)) + + self.repo.update_locked_pair( + first.id, + second.id, + lambda sender, receiver: ( + replace(sender, credit_balance = 60.0), + replace(receiver, credit_balance = 65.0), + ), + commit = False, + ) + self.sql.get_session().rollback() + + self.assertEqual(self.repo.get(first.id).credit_balance, 100.0) + self.assertEqual(self.repo.get(second.id).credit_balance, 25.0) + def test_update_locked_pair_raises_when_missing(self): existing = self.repo.save(self.__user(connect_key = "PAIR-MISSING")) diff --git a/test/util/test_config.py b/test/util/test_config.py index 8fde1fbe..2e48f3e8 100644 --- a/test/util/test_config.py +++ b/test/util/test_config.py @@ -67,6 +67,8 @@ def test_default_config(self): self.assertEqual(config.url_shortener_base_url, "https://urls.appifyhub.com") self.assertEqual(config.version, "dev") self.assertEqual(config.usage_maintenance_fee_credits, 0.0) + self.assertEqual(config.welcome_credit_grant_amount, 500.0) + self.assertEqual(config.welcome_credit_grant_eligibility_days, 7) self.assertEqual(config.products_config_path, "config/products.yaml") self.assertEqual(config.logos_config_path, "config/logos.yaml") self.assertEqual(config.fonts_dir, "src/assets/fonts") @@ -146,6 +148,8 @@ def test_custom_config(self): os.environ["URL_SHORTENER_BASE_URL"] = "https://custom.to.appifyhub.com" os.environ["VERSION"] = "custom" os.environ["USAGE_MAINTENANCE_FEE_CREDITS"] = "0.5" + os.environ["WELCOME_CREDIT_GRANT_AMOUNT"] = "750.0" + os.environ["WELCOME_CREDIT_GRANT_ELIGIBILITY_DAYS"] = "14" os.environ["FONTS_DIR"] = "/custom/path/fonts" os.environ["S3_BASE_URL"] = "https://s3.example.com" os.environ["S3_REGION"] = "eu-west-1" @@ -228,6 +232,8 @@ def test_custom_config(self): self.assertEqual(config.url_shortener_base_url, "https://custom.to.appifyhub.com") self.assertEqual(config.version, "custom") self.assertEqual(config.usage_maintenance_fee_credits, 0.5) + self.assertEqual(config.welcome_credit_grant_amount, 750.0) + self.assertEqual(config.welcome_credit_grant_eligibility_days, 14) self.assertEqual(config.fonts_dir, "/custom/path/fonts") self.assertEqual(config.s3_base_url, "https://s3.example.com") self.assertEqual(config.s3_region, "eu-west-1")