Skip to content

feat: add transactional expiring balance credits - #906

Open
thekfjie wants to merge 1 commit into
DEEIX-AI:devfrom
thekfjie:feat/expiring-credit
Open

thekfjie wants to merge 1 commit into
DEEIX-AI:devfrom
thekfjie:feat/expiring-credit

Conversation

@thekfjie

Copy link
Copy Markdown
Contributor

Summary

Add expiring balance credits for administrator grants and usage-mode redemption codes. This feature is independent of the file-response fix in #905.

  • Support independent credit batches with durations from 0.1 to 3650 days, including fractional days stored as whole seconds.
  • Consume the earliest-expiring active credit first, then permanent balance.
  • Reclaim only each batch's unused amount at expiration, with account, batch and ledger updates in the same transaction.
  • Make administrator grant retries idempotent and reject reuse of a key with different grant details.
  • Add grant controls to the user editor, optional balance lifetimes to redemption codes, and corresponding localized messages and generated API contracts.

Behavior and approach

Keep the existing account balance as the total balance and track the expirable portion in billing_expiring_credits. All balance mutations lock the account before its credit batches. Balance-sensitive operations capture one timestamp after acquiring the account lock, so a lock wait crossing a credit's expiration cannot leave expired funds available.

Balance-sensitive reads, deposits, redemptions, reservations and settlement expire due credits before calculating funds. A cancellable background worker also sweeps on startup and every minute. Administrator account lists prefetch due user IDs in one query and expire only the requested users, avoiding one expiration lookup per account.

A grant first repays existing debt; only its unspent remainder can expire. Administrative reductions also reduce active batches, preventing a later expiration from charging the same funds again. A reservation does not extend a grant's lifetime. Actual usage settling after expiration continues to follow the existing full-cost settlement behavior and may leave a negative balance.

Usage-mode redemption codes use optional balanceDurationDays; subscription codes retain integer durationDays. The code's own redemption deadline remains independent of the awarded balance's lifetime.

Change type

  • Feature
  • Documentation

Affected areas

  • Frontend / UI
  • Backend / API
  • Authentication / authorization through the administrator and billing feature gates
  • Billing / payments
  • Admin console
  • Documentation

Verification

Backend checks used Go 1.26.10. Frontend and contract checks used Node 26.12.1 with pnpm 10.17.0.

  • make lint
  • go test ./... -count=1
  • go build ./...
  • go build -tags nopostgres,noredis,nos3,noswagger,nomsgpack,nootlp ./...
  • SQLite regression tests for consumption order, credit redemption, expiration, ledger rollback, idempotent retries, debt repayment, administrative reductions, reservation rechecks, delayed payment callbacks and settlement after expiry.
  • Real dedicated PostgreSQL 17 database: concurrent grants with one idempotency key create one deposit; concurrent sweepers create one expiration deduction; changed grant details conflict.
  • go test -race ./internal/application/billing ./internal/infra/persistence/postgres/billing ./internal/transport/http/billing -count=1, with DEEIX_TEST_DATABASE_DSN set to the dedicated PostgreSQL database.
  • New regressions fail before the fixes and pass afterward: bounded expiration lookups for a 32-account list, subscription snapshots without balance lifetime metadata, and a real PostgreSQL account-lock wait crossing credit expiration.
  • Grant input validation, application-boundary error mapping and local-mode feature-gate coverage.
  • pnpm api:generate
  • pnpm api:check
  • pnpm --filter @deeix/web check
  • pnpm --filter @deeix/web build
  • git diff --check
  • Full browser interaction of the administrator forms.
  • Full Rust desktop packaging and an upgrade against a production database.

Screenshots, API examples, or logs

Administrator grant:

POST /api/v1/admin/billing/accounts/42/expiring-credits

{
"creditUSD": 20,
"durationDays": 0.1,
"idempotencyKey": "campaign-user-42-001",
"description": "Trial credit"
}

The response contains the original grant and the current account balance. Retrying with identical details does not deposit again. Changing the amount, lifetime, administrator or description while reusing the key returns a conflict.

A usage-mode redemption code can set balanceDurationDays=0.1. Omission or zero keeps its award permanent. The balance lifetime starts when the code is redeemed.

Configuration, migration, and compatibility notes

  • Add billing_expiring_credits and billing_redemption_codes.balance_duration_seconds through the shared schema migration.
  • Existing permanent balances and codes keep their current behavior. Existing custom grant rows retain their amounts and expiration times; missing grant-duration metadata defaults to zero.
  • Redemption responses and snapshots expose balanceDurationSeconds. Previous custom fractional balance snapshots remain readable.
  • Reuse the existing administrator authorization and billingGating gate; this does not enable billing features in local desktop mode.
  • Regenerate Swagger and TypeScript contracts through the repository pipeline.
  • No additional service, queue, secret or environment variable is needed.
  • Back up the database before rollout; rollback should restore the matching pre-upgrade database.

Documentation

  • Add docs/EXPIRING_CREDITS.md covering API use, lifetime semantics, accounting, schema compatibility and test setup.

Security and privacy

  • The patch excludes private deployment settings, account data and credentials.
  • Administrator identity comes from authenticated middleware.
  • Grant validation and idempotency conflicts are mapped at the application boundary.
  • Ledger, account and batch updates commit together; real PostgreSQL locking and concurrent retries were exercised.

Checklist

  • Current upstream code and related PRs were checked for an existing implementation.
  • The feature is separate from the file-response bug fix.
  • Administrator impersonation and unrelated display customizations are excluded.
  • Relevant automated checks passed; unperformed manual checks are identified above.
  • Generated API artifacts are included; build output, caches and local data are excluded.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant