Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
114 commits
Select commit Hold shift + click to select a range
e7eb7b4
feat(pay-with-ai3): purchased_credits schema & repository (Phase 0)
EmilFattakhov Mar 2, 2026
9571aa8
Merge pull request #605 from autonomys/main
jim-counter Mar 5, 2026
f2e24d3
Merge pull request #607 from autonomys/main
EmilFattakhov Mar 9, 2026
44d6b09
Merge pull request #600 from autonomys/feat/pay-with-ai3/step-01-cred…
EmilFattakhov Mar 10, 2026
926aa50
Merge pull request #608 from autonomys/fix/zlib-decompression-fallbac…
EmilFattakhov Mar 10, 2026
1229d4f
Merge pull request #609 from autonomys/main
EmilFattakhov Mar 10, 2026
a358839
fix: only refresh JWT when token is near expiry
jim-counter Mar 10, 2026
70ec7e0
fix: increase session memoization TTL from 500ms to 30s
jim-counter Mar 10, 2026
f582b07
fix: remove duplicate getMe() call from UserProtectedLayout
jim-counter Mar 10, 2026
8e1ded1
fix: use accessTokenExp instead of exp for token refresh check
jim-counter Mar 10, 2026
d864b4f
docs: add comment about stale session edge case with 30s TTL
jim-counter Mar 10, 2026
d75c52e
fix: don't cache failed or null results in memoizePromise
jim-counter Mar 10, 2026
8970271
fix: clear session cache on sign-out to prevent stale credentials
jim-counter Mar 10, 2026
57f6e96
fix: clear user store on sign-out and extract useLogOut hook
jim-counter Mar 11, 2026
714597f
Merge pull request #610 from autonomys/fix/session-request-storm
jim-counter Mar 11, 2026
96c0511
Merge branch 'production' into main
jim-counter Mar 11, 2026
3d4924b
feat(credits): ai3/step 02 wire purchased credits into core business …
EmilFattakhov Mar 12, 2026
5e80523
Feat/pay with ai3/step 03 intent expiry (#602)
EmilFattakhov Mar 13, 2026
4971d62
feat(pay-with-ai3): ai3/step 04 gate buyCredits on Google auth for pu…
EmilFattakhov Mar 13, 2026
331100d
feat(credits): add Credit API endpoints (Task 2.1)
EmilFattakhov Mar 6, 2026
16346b9
fix(credits): use SQL aggregate for getEconomics instead of fetching …
EmilFattakhov Mar 16, 2026
bf89cac
feat(pay-with-ai3): step-06 add OVER_CAP intent status to stop infini…
EmilFattakhov Mar 13, 2026
fc70598
feat(pay-with-ai3): step-06 add OVER_CAP intent status with admin rep…
EmilFattakhov Mar 13, 2026
3822704
fix(tests): replace dynamic neverthrow imports with static err import
EmilFattakhov Mar 13, 2026
1912649
fix(tests): use UserRole enum instead of lowercase string literals
EmilFattakhov Mar 16, 2026
4c4f3ed
feat(frontend): step 07 – wire credit summary, cap guard, and expiry UX
EmilFattakhov Mar 16, 2026
d6af2be
fix(credits): source credit expiry duration from CREDIT_EXPIRY_DAYS e…
EmilFattakhov Mar 16, 2026
72d351e
fix(frontend): add isOverCap guard to backend polling useEffect
EmilFattakhov Mar 16, 2026
24b1c73
fix(frontend): clamp daysUntilExpiry to non-negative and handle expir…
EmilFattakhov Mar 16, 2026
fb2fe4a
fix(tests): align daysUntilExpiry test with Math.max(0) clamp
EmilFattakhov Mar 16, 2026
d9e64ab
fix(frontend): exclude creditSummary from Zustand localStorage persis…
EmilFattakhov Mar 16, 2026
1693de9
refactor(credits): scope purchased credits to uploads only
EmilFattakhov Mar 18, 2026
90a4b96
test(credits): update unit tests to reflect upload-only cap
EmilFattakhov Mar 18, 2026
764c7e6
feat(ui): show purchased credits in AccountInformation sidebar widget
EmilFattakhov Mar 18, 2026
2d94fc5
feat(sidebar): thread creditSummary into AccountInformation widget
EmilFattakhov Mar 18, 2026
5d3c007
fix(purchase): correct balance and after-purchase total in Step2
EmilFattakhov Mar 18, 2026
62fb55e
Merge pull request #606 from autonomys/feat/pay-with-ai3/step-05-cred…
EmilFattakhov Mar 19, 2026
51778ad
feat(purchase): show new purchased credits total on Step4 success screen
EmilFattakhov Mar 19, 2026
7e67b55
fix(uploads): add credit guard to folder upload finalisation
EmilFattakhov Mar 19, 2026
32d417e
feat(api): add getCreditBatches() for full purchase history
EmilFattakhov Mar 19, 2026
fa6703f
feat(credits): add /drive/credits purchase history page
EmilFattakhov Mar 19, 2026
8c01767
feat(sidebar): add 'View history →' link to purchased credits widget
EmilFattakhov Mar 19, 2026
a4a6989
Merge pull request #615 from autonomys/feat/pay-with-ai3/step-06-over…
EmilFattakhov Mar 19, 2026
efa409a
fix(intents): add idempotency guard to markIntentAsConfirmed
EmilFattakhov Mar 20, 2026
e42e02c
fix(intents): guard against dust payments yielding zero credits
EmilFattakhov Mar 20, 2026
82ef1d1
fix(paymentManager): recover orphaned PENDING+txHash intents on startup
EmilFattakhov Mar 20, 2026
9cda570
docs(env): document all pay-with-AI3 env vars in .env.sample
EmilFattakhov Mar 20, 2026
aa36547
feat(admin): add purchased-credits panel to admin dashboard
EmilFattakhov Mar 20, 2026
3d8a41d
fix(credits): suppress buy-more CTA flash during loading
EmilFattakhov Mar 20, 2026
7224c15
fix(intents): transition dust-payment intents to FAILED instead of re…
EmilFattakhov Mar 20, 2026
fe741f8
fix(admin): gate Reprocess button on isPending to avoid stuck disable…
EmilFattakhov Mar 20, 2026
d567e62
refactor(credits): extract batch status helpers into shared utils/cre…
EmilFattakhov Mar 20, 2026
edb0a36
fix(intents): include FAILED and EXPIRED in markIntentAsConfirmed ide…
EmilFattakhov Mar 20, 2026
aef581e
Add GitHub Actions workflow for Claude code review
todd-subspace Mar 20, 2026
b84c080
Pin action versions to commit hashes for supply chain security
todd-subspace Mar 20, 2026
f6f6d38
fix: pin flatted >=3.4.0 to address GHSA-25h7-pfq9-p65f
jim-counter Mar 23, 2026
071304d
fix: pin fast-xml-parser >=5.5.6 to address GHSA-8gc5-j5rx-235r
jim-counter Mar 23, 2026
46fcbec
fix: pin minimatch per major version to address GHSA-7r86-cg39-jmmj
jim-counter Mar 23, 2026
c39741c
fix: pin undici >=6.24.0 to address GHSA-vrm6-8vpv-qv8q
jim-counter Mar 23, 2026
758e3b0
fix: pin h3 >=1.15.6 to address GHSA-22cc-p3c6-wpvm
jim-counter Mar 23, 2026
dfdac12
fix: pin socket.io-parser >=4.2.6 to address GHSA-677m-j7p3-52f9
jim-counter Mar 23, 2026
a58ac6a
fix: correct minimatch resolution descriptors to use npm: protocol
jim-counter Mar 23, 2026
9385651
chore: update yarn.lock with patched dependency resolutions
jim-counter Mar 23, 2026
63fdd08
fix: tighten socket.io-parser resolution to tilde range (~4.2.6)
jim-counter Mar 23, 2026
fdbeb25
fix(frontend): treat expired intent as a terminal polling state
EmilFattakhov Mar 24, 2026
d0f64d9
fix(frontend): use Math.floor in daysUntilExpiry so expiry-today shows 0
EmilFattakhov Mar 24, 2026
20f6a38
fix(frontend): handle expired intent state in Step3_TransferTokens
EmilFattakhov Mar 24, 2026
7cb8de7
fix(frontend): detect expired intents via HTTP 410 instead of unreach…
EmilFattakhov Mar 24, 2026
c6b310d
Merge pull request #616 from autonomys/feat/pay-with-ai3/step-07-fron…
EmilFattakhov Mar 24, 2026
d20b8fa
Merge branch 'main' into feat/pay-with-ai3/step-08-credit-balance-dis…
EmilFattakhov Mar 24, 2026
fb4f142
fix(credits): coerce hasBuyCreditsFeature to boolean for TanStack Query
EmilFattakhov Mar 25, 2026
f167a93
Merge pull request #623 from autonomys/fix/dependency-vulnerability-r…
jim-counter Mar 25, 2026
862e156
Merge pull request #622 from autonomys/todd-subspace-patch-1
jim-counter Mar 25, 2026
3872090
Merge pull request #618 from autonomys/feat/pay-with-ai3/step-08-cred…
EmilFattakhov Mar 25, 2026
552bdf9
Merge branch 'main' into feat/pay-with-ai3/step-09-credit-history
EmilFattakhov Mar 26, 2026
5284092
feat: add in-app banner notification system
jim-counter Mar 27, 2026
c19e246
fix: add htmlFor/id to form labels for jsx-a11y compliance
jim-counter Mar 27, 2026
0643635
merge: resolve conflicts with main (keep both BannerNotifications and…
jim-counter Mar 27, 2026
46d431b
refactor: unify ExpiryWarningBanner with banner notification styling
jim-counter Mar 27, 2026
1b5c2db
fix: add response.ok check to interactWithBanner and remove dead code
jim-counter Mar 27, 2026
ded67bf
fix: disambiguate empty update from missing banner in updateBanner
jim-counter Mar 27, 2026
bc4adf8
fix: remove extra space before period in expiry warning text
jim-counter Mar 27, 2026
243a37e
fix(banners): refresh notification area immediately after admin mutat…
jim-counter Mar 27, 2026
4512ac2
test(banners): add unit tests for admin-only endpoint authorization
jim-counter Mar 27, 2026
17d5ed5
fix(banners): remove unnecessary as-any casts in unit tests
jim-counter Mar 27, 2026
95f8f7d
Merge pull request #630 from autonomys/feat/banner-notification-system
jim-counter Mar 30, 2026
134ee61
feat(upload): pre-flight credit check before folder upload starts
EmilFattakhov Mar 30, 2026
f480935
Merge branch 'main' into feat/pay-with-ai3/step-09-credit-history
EmilFattakhov Mar 30, 2026
e24f0ef
feat: add Terms of Use versioning, acceptance tracking, and re-consen…
jim-counter Mar 30, 2026
8e9751d
fix(credits): show "View history" link even when purchased credits ar…
EmilFattakhov Mar 30, 2026
6336d4e
docs: add worktree submodule warning to CLAUDE.md
jim-counter Mar 30, 2026
ace74aa
fix(dev): pin PostgreSQL to v17 to avoid v18 data directory incompati…
jim-counter Mar 30, 2026
fdda99b
fix(frontend): handle stale sessions gracefully instead of crashing
jim-counter Mar 30, 2026
ca6a58c
fix(tou): address issues found during manual testing
jim-counter Mar 30, 2026
41aa280
fix(tou): address bugbot review findings
jim-counter Mar 30, 2026
ba1700e
fix(tou): block content while ToU status loads and remove dead code
jim-counter Mar 30, 2026
ec4a2af
fix(tou): fail-closed on ToU status errors and remove non-null assert…
jim-counter Mar 30, 2026
a7d072c
fix(tou): remove raw URL from advance notice banner body
jim-counter Mar 30, 2026
f353ce9
Merge pull request #620 from autonomys/feat/pay-with-ai3/step-09-cred…
EmilFattakhov Mar 30, 2026
901b8a5
Merge branch 'main' into feat/pay-with-ai3/step-10-payment-robustness
EmilFattakhov Mar 31, 2026
bd44016
feat(tou): add instructional boilerplate to admin page
jim-counter Mar 31, 2026
4d92066
Merge branch 'main' into feat/tou-versioning-acceptance
jim-counter Mar 31, 2026
00baf88
Merge pull request #621 from autonomys/feat/pay-with-ai3/step-10-paym…
EmilFattakhov Mar 31, 2026
979c9de
fix(intents): add idempotency guard to markIntentAsConfirmed
EmilFattakhov Mar 20, 2026
cbc0dcf
fix(intents): guard against dust payments yielding zero credits
EmilFattakhov Mar 20, 2026
915fa00
fix(paymentManager): recover orphaned PENDING+txHash intents on startup
EmilFattakhov Mar 20, 2026
c76fe12
docs(env): document all pay-with-AI3 env vars in .env.sample
EmilFattakhov Mar 20, 2026
6c08b72
feat(admin): add purchased-credits panel to admin dashboard
EmilFattakhov Mar 20, 2026
5c859a1
fix(intents): transition dust-payment intents to FAILED instead of re…
EmilFattakhov Mar 20, 2026
de299d6
fix(admin): gate Reprocess button on isPending to avoid stuck disable…
EmilFattakhov Mar 20, 2026
6f2486b
refactor(credits): extract batch status helpers into shared utils/cre…
EmilFattakhov Mar 20, 2026
7097b37
fix(intents): include FAILED and EXPIRED in markIntentAsConfirmed ide…
EmilFattakhov Mar 20, 2026
46522ca
Merge remote-tracking branch 'origin/main' into feat/tou-versioning-a…
jim-counter Mar 31, 2026
ca24477
Merge pull request #631 from autonomys/feat/tou-versioning-acceptance
jim-counter Mar 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/claude-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Claude Code Review

on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]

jobs:
claude-review:
runs-on: ubuntu-latest
if: |
(github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude'))
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 1
- uses: anthropics/claude-code-action@df37d2f0760a4b5683a6e617c9325bc1a36443f6 # v1.0.75
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: |
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ Yarn 4.2.2 monorepo with workspaces in `apps/`, `packages/`, and `submodules/`.
# Install dependencies
yarn install

# Initialize git submodules (required first time)
# Initialize git submodules (required first time, and in each new worktree)
make init-submodules
# ⚠️ Worktrees do NOT inherit initialized submodules from the main repo.
# If `yarn install` fails with "Workspace not found (@auto-files/rpc-apis)",
# run `git submodule update --init --recursive` or `make init-submodules`.

# Build everything (submodules + models + ui + s3 + frontend + backend)
make all
Expand Down
59 changes: 57 additions & 2 deletions apps/backend/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,60 @@ FILES_GATEWAY_URL=http://changeme.com # change to your own (or it'll fail when f
FILES_GATEWAY_TOKEN=changeme # change to your own (or it'll fail when fetching archived files)
AUTH_SERVICE_API_KEY=1234567890 # change to your own if updated in auth
RABBITMQ_URL=amqp://guest:guest@localhost:5672
EVM_CHAIN_ENDPOINT=http://localhost:8545 # update it you want to simulate/test buy credits feature
EVM_CHAIN_CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000 # update it you want to simulate/test buy credits feature

# ---------------------------------------------------------------------------
# Pay-with-AI3 / purchased credits feature
# ---------------------------------------------------------------------------

# Feature flags — set BUY_CREDITS_ACTIVE=true to enable the purchase flow.
# BUY_CREDITS_STAFF_ONLY=true restricts it to admin/staff accounts only,
# useful for a staged rollout before opening to all users.
BUY_CREDITS_ACTIVE=false
BUY_CREDITS_STAFF_ONLY=false

# EVM endpoint for the AutoDriveCreditsReceiver contract.
# Point this at the Auto-EVM RPC for the target network (mainnet or Taurus
# testnet). The payment manager watches this chain for deposit events.
EVM_CHAIN_ENDPOINT=http://localhost:8545

# Address of the deployed AutoDriveCreditsReceiver contract.
# Must match the chain pointed to by EVM_CHAIN_ENDPOINT.
EVM_CHAIN_CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000

# Number of block confirmations to wait before treating a payment as final.
# Higher values reduce the risk of processing a payment that is later
# reversed by a chain reorganisation. Default: 6.
EVM_CHAIN_CONFIRMATIONS=6

# How often (in milliseconds) the payment manager polls for CONFIRMED intents
# that have not yet had credits applied. This is a fallback for cases where
# the event watcher misses a log. Default: 30000 (30 seconds).
EVM_CHAIN_CHECK_INTERVAL=30000

# Price multiplier applied on top of the raw Autonomys consensus fee to
# determine the AI3 cost per byte. A value of 5.0 means users pay 5× the
# current on-chain transaction byte fee. Default: 5.00.
CREDITS_PRICE_MULTIPLIER=5.00

# ---------------------------------------------------------------------------
# Credit lifecycle
# ---------------------------------------------------------------------------

# Number of days after purchase before a credit batch expires.
# Users see this value on the purchase confirmation screen and in their credit
# history. Default: 90.
CREDIT_EXPIRY_DAYS=90

# Maximum total purchased upload bytes allowed per user across all active
# (non-expired) credit rows. Attempts to purchase beyond this cap result in
# an OVER_CAP intent requiring admin review. Default: 107374182400 (100 GiB).
MAX_CREDITS_PER_USER=107374182400

# How often (in milliseconds) the background job runs to mark expired credit
# rows and clean up stale PENDING intents. Default: 3600000 (1 hour).
CREDIT_EXPIRY_CHECK_INTERVAL=3600000

# How many minutes a PENDING intent remains valid before it is treated as
# expired. Users must submit their on-chain transaction within this window
# after creating an intent. Default: 10.
INTENT_EXPIRY_MINUTES=10
213 changes: 189 additions & 24 deletions apps/backend/__tests__/e2e/users/credits.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
InteractionType,
InteractionSource,
AccountModel,
IntentStatus,
UserWithOrganization,
} from '@auto-drive/models'
import { PreconditionError } from '../../utils/error.js'
Expand All @@ -14,7 +16,22 @@ import { dbMigration } from '../../utils/dbMigrate.js'
import { AccountsUseCases } from '../../../src/core/users/accounts.js'
import { AuthManager } from '../../../src/infrastructure/services/auth/index.js'
import { accountsRepository } from '../../../src/infrastructure/repositories/index.js'
import { purchasedCreditsRepository } from '../../../src/infrastructure/repositories/users/purchasedCredits.js'
import { interactionsRepository } from '../../../src/infrastructure/repositories/objects/interactions.js'
import { jest } from '@jest/globals'
import { v4 as uuidv4 } from 'uuid'

// Inserts a minimal intent row so the purchased_credits.intent_id FK is satisfied.
const createTestIntent = async (userPublicId: string): Promise<string> => {
const db = await getDatabase()
const id = `test-intent-${uuidv4()}`
await db.query(
`INSERT INTO intents (id, user_public_id, status, shannons_per_byte, expires_at)
VALUES ($1, $2, $3, $4, $5)`,
[id, userPublicId, IntentStatus.COMPLETED, '1', new Date()],
)
return id
}

describe('CreditsUseCases', () => {
let mockUser: UserWithOrganization
Expand All @@ -33,7 +50,7 @@ describe('CreditsUseCases', () => {
await dbMigration.down()
})

it('should create credits for a user', async () => {
it('should consume free-tier credits on upload interaction', async () => {
const interactionType = InteractionType.Upload
const size = BigInt(1024)

Expand All @@ -54,7 +71,7 @@ describe('CreditsUseCases', () => {
expect(initialCredits - pendingCredits).toEqual(Number(size))
})

it('should create credits for a user on download', async () => {
it('should consume free-tier credits on download interaction', async () => {
const interactionType = InteractionType.Download
const size = BigInt(2048)

Expand All @@ -75,50 +92,198 @@ describe('CreditsUseCases', () => {
expect(initialCredits - pendingCredits).toBe(Number(size).valueOf())
})

it('should add credits to a OneOff subscription', async () => {
it('should create a purchased_credits row when credits are added', async () => {
jest.spyOn(AuthManager, 'getUserFromPublicId').mockResolvedValue(mockUser)
const subscription = await AccountsUseCases.getOrCreateAccount(mockUser)
// Ensure subscription is OneOff
await accountsRepository.updateAccount(
subscription.id,
AccountModel.OneOff,
subscription.uploadLimit,
subscription.downloadLimit,
)
const account = await AccountsUseCases.getOrCreateAccount(mockUser)

const before = await AccountsUseCases.getAccountById(subscription.id)
if (!before) throw new Error('Subscription not found')
const creditsToAdd = BigInt(123)
const intentId = await createTestIntent(mockUser.publicId)

const creditsToAdd = 123
const result = await AccountsUseCases.addCreditsToAccount(
mockUser.publicId,
creditsToAdd,
intentId,
)

expect(result.isOk()).toBe(true)

const after = await AccountsUseCases.getAccountById(subscription.id)
if (!after) throw new Error('Subscription not found')
// A purchased_credits row should now exist for this account.
const rows = await purchasedCreditsRepository.getByAccountId(account.id)
expect(rows).toHaveLength(1)
expect(rows[0].uploadBytesOriginal).toBe(creditsToAdd)
expect(rows[0].uploadBytesRemaining).toBe(creditsToAdd)
expect(rows[0].intentId).toBe(intentId)

// The free/one-off account limits must remain untouched.
const after = await AccountsUseCases.getAccountById(account.id)
if (!after) throw new Error('Account not found')
expect(after.uploadLimit).toBe(account.uploadLimit)
expect(after.downloadLimit).toBe(account.downloadLimit)
})

it('purchased credits should count toward the total available credits', async () => {
jest.spyOn(AuthManager, 'getUserFromPublicId').mockResolvedValue(mockUser)
const account = await AccountsUseCases.getOrCreateAccount(mockUser)

const creditsToAdd = BigInt(100 * 1024)
const intentId = await createTestIntent(mockUser.publicId)

const before = await AccountsUseCases.getPendingCreditsByUserAndType(
mockUser,
InteractionType.Upload,
)

await AccountsUseCases.addCreditsToAccount(
mockUser.publicId,
creditsToAdd,
intentId,
)

const after = await AccountsUseCases.getPendingCreditsByUserAndType(
mockUser,
InteractionType.Upload,
)

expect(after.uploadLimit - before.uploadLimit).toBe(creditsToAdd)
expect(after.model).toBe(AccountModel.OneOff)
// The total pending credits must have grown by exactly the purchased amount.
expect(after - before).toBe(Number(creditsToAdd))

// And the free-tier account limits remain unchanged.
const acct = await AccountsUseCases.getAccountById(account.id)
if (!acct) throw new Error('Account not found')
expect(acct.uploadLimit).toBe(account.uploadLimit)
})

it('should fail to add credits if subscription is not OneOff', async () => {
it('should add credits to accounts of any model (not restricted to OneOff)', async () => {
jest.spyOn(AuthManager, 'getUserFromPublicId').mockResolvedValue(mockUser)
const subscription = await AccountsUseCases.getOrCreateAccount(mockUser)
const account = await AccountsUseCases.getOrCreateAccount(mockUser)

// Switch to Monthly model — purchased credits should still work.
await accountsRepository.updateAccount(
subscription.id,
account.id,
AccountModel.Monthly,
subscription.uploadLimit,
subscription.downloadLimit,
account.uploadLimit,
account.downloadLimit,
)

const intentId = await createTestIntent(mockUser.publicId)
const result = await AccountsUseCases.addCreditsToAccount(
mockUser.publicId,
10,
BigInt(50),
intentId,
)

expect(result.isOk()).toBe(true)

const rows = await purchasedCreditsRepository.getByAccountId(account.id)
expect(rows).toHaveLength(1)
expect(rows[0].uploadBytesOriginal).toBe(BigInt(50))
})

it('should reject a purchase that would exceed the per-user cap', async () => {
jest.spyOn(AuthManager, 'getUserFromPublicId').mockResolvedValue(mockUser)

// Manually insert a row that consumes most of the cap (100 GiB).
// We mock getRemainingCredits to simulate a near-full account rather than
// inserting 100 GiB worth of real rows.
const account = await AccountsUseCases.getOrCreateAccount(mockUser)

// Insert a large existing credit row directly so the cap check fires.
const largeBytes = BigInt(100 * 1024 * 1024 * 1024) // 100 GiB
const existingIntentId = await createTestIntent(mockUser.publicId)
await purchasedCreditsRepository.createPurchasedCredit({
accountId: account.id,
intentId: existingIntentId,
uploadBytesOriginal: largeBytes,
downloadBytesOriginal: largeBytes,
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
})

// Trying to add even 1 more byte should be rejected.
const overCapIntentId = await createTestIntent(mockUser.publicId)
const result = await AccountsUseCases.addCreditsToAccount(
mockUser.publicId,
BigInt(1),
overCapIntentId,
)

expect(result.isErr()).toBe(true)
})

it('should prevent double-spend when two concurrent 90 MB uploads race against 100 MB of purchased credits', async () => {
jest.spyOn(AuthManager, 'getUserFromPublicId').mockResolvedValue(mockUser)
const account = await AccountsUseCases.getOrCreateAccount(mockUser)

const MB = (n: number) => BigInt(n * 1024 * 1024)

// Seed exactly 100 MB of purchased credits and zero out the free-tier
// allocation so the only available budget is the purchased pool.
const intentId = await createTestIntent(mockUser.publicId)
await purchasedCreditsRepository.createPurchasedCredit({
accountId: account.id,
intentId,
uploadBytesOriginal: MB(100),
downloadBytesOriginal: MB(100),
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
})
await accountsRepository.updateAccount(
account.id,
AccountModel.OneOff,
0, // uploadLimit = 0 (no free-tier bytes)
0, // downloadLimit = 0
)

// Fire two concurrent 90 MB upload interactions simultaneously.
// consumeUpTo serialises access via FOR UPDATE: one caller gets 90 MB,
// the other only gets 10 MB from purchased credits. The free-tier guard
// then detects that the remaining 80 MB cannot be covered by free-tier
// (uploadLimit = 0), refunds the 10 MB back, and throws.
const [result1, result2] = await Promise.allSettled([
AccountsUseCases.registerInteraction(
mockUser,
InteractionType.Upload,
MB(90),
),
AccountsUseCases.registerInteraction(
mockUser,
InteractionType.Upload,
MB(90),
),
])

// Exactly one upload succeeds and exactly one is rejected.
const statuses = [result1.status, result2.status].sort()
expect(statuses).toEqual(['fulfilled', 'rejected'])

// The rejected upload must have thrown PaymentRequiredError and refunded
// the partial purchased-credit deduction. 10 MB (= 100 - 90) must remain.
const remaining =
await purchasedCreditsRepository.getRemainingCredits(account.id)
expect(remaining.uploadBytesRemaining).toBe(MB(10))

// Only 90 MB should be recorded as consumed from purchased credits —
// the second caller's partial deduction was rolled back via refundCredits.
const now = new Date()
const purchasedInteractions =
await interactionsRepository.getInteractionsByAccountIdAndTypeInTimeRange(
account.id,
InteractionType.Upload,
new Date(0),
now,
InteractionSource.Purchased,
)
const totalPurchasedConsumed = purchasedInteractions.reduce(
(sum, i) => sum + i.size,
0,
)
expect(totalPurchasedConsumed).toBe(Number(MB(90)))

// After the race, only 10 MB of purchased credits remain and 0 free-tier
// bytes are available — a new 90 MB upload must be denied at the gate.
const pendingAfter =
await AccountsUseCases.getPendingCreditsByUserAndType(
mockUser,
InteractionType.Upload,
)
expect(pendingAfter).toBe(Number(MB(10)))
})
})
Loading
Loading