From d835be985e2402a426b61d16297c48b04d983180 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 10 Mar 2026 12:31:56 +0000 Subject: [PATCH] added tests / finalize for check with lock --- .gitignore | 2 + .plans/check-reserve/04_region_cache.md | 1 + .zed/settings.json | 56 ++ bun.lock | 179 ++++ server/package.json | 1 + .../deductFromCustomerEntitlements.lua | 40 +- .../deduction/lock/claimLockReceipt.lua | 22 + .../deduction/lock/lockExpiryUtils.lua | 23 - .../deduction/lock/lockReceipt.lua | 9 +- .../deduction/lock/lockStateUtils.lua | 87 +- .../deduction/lock/unwindAndDeduct.lua | 78 -- .../deduction/lock/unwindLockUtils.lua | 63 +- server/src/_luaScriptsV2/luaScriptsV2.ts | 20 +- .../aws/eventbridge/eventBridgeUtils.ts | 84 ++ .../aws/eventbridge/initEventBridge.ts | 13 + .../roles/schedulerPermissionsPolicyDev.json | 10 + .../roles/schedulerPermissionsPolicyProd.json | 10 + .../aws/roles/schedulerTrustPolicy.json | 12 + server/src/external/redis/initRedis.ts | 14 +- server/src/internal/api/check/handleCheck.ts | 7 +- .../internal/api/check/runCheckWithTrack.ts | 35 +- .../finalizeLock/buildFinalizeLockContext.ts | 80 ++ .../executeRedisUnwindAndDeduct.ts | 0 .../balances/finalizeLock/expireLock.ts | 32 + .../balances/finalizeLock/finalizeLock.ts | 139 --- .../finalizeLock/insertFinalizeLockEvent.ts | 29 + .../balances/finalizeLock/runFinalizeLock.ts | 41 + .../finalizeLock/runPostgresFinalizeLock.ts | 32 + .../finalizeLock/runRedisFinalizeLock.ts | 69 ++ .../balances/handlers/handleFinalizeLock.ts | 4 +- .../deduction/executePostgresDeduction.ts | 6 + .../utils/deduction/executeRedisDeduction.ts | 25 +- .../deduction/prepareFeatureDeduction.ts | 7 + .../balances/utils/lock/cancelLockExpiry.ts | 10 + .../balances/utils/lock/claimLockReceipt.ts | 56 ++ .../balances/utils/lock/deleteLockReceipt.ts | 18 + .../balances/utils/lock/fetchLockReceipt.ts | 1 + .../utils/lock/parseCheckParamsForLock.ts | 27 +- .../balances/utils/lock/saveLockReceipt.ts | 11 +- .../balances/utils/sql/performDeduction.sql | 13 +- .../utils/sql/unwindFromLockReceipt.sql | 63 +- .../utils/sync/SyncBatchingManagerV2.ts | 14 +- .../sync/deductionUpdatesToModifiedIds.ts | 2 +- .../balances/utils/types/deductionTypes.ts | 3 +- server/src/queue/JobName.ts | 3 + server/src/queue/initSqs.ts | 5 +- server/src/queue/initWorkers.ts | 395 ++++---- server/src/queue/processMessage.ts | 16 +- server/src/queue/queueUtils.ts | 28 +- server/src/queue/workflows.ts | 54 +- server/tests/_temp/temp.test.ts | 119 +-- server/tests/balances/testBalanceUtils.ts | 24 - .../track/allocated/track-allocated5.test.ts | 2 +- .../track-entity-balances6.test.ts | 2 +- .../track/rollovers/track-rollover4.test.ts | 2 +- .../check-with-lock-additional-deduct.test.ts | 218 +++++ .../check-with-lock-refund-breakdown.test.ts | 203 ++++ .../lock/basic/check-with-lock-refund.test.ts | 231 +++++ .../basic/check-with-lock-release.test.ts | 170 ++++ .../lock/check-with-lock-basic.test.ts | 54 -- .../check-with-lock-concurrent-stress.test.ts | 198 ++++ .../check-with-lock-credit-system.test.ts | 877 ++++++++++++++++++ .../lock/check-with-lock-edge-cases.test.ts | 365 ++++++++ .../lock/check-with-lock-errors.test.ts | 41 + .../lock/check-with-lock-expiry.test.ts | 255 +++++ .../lock/check-with-lock-postgres.test.ts | 61 -- .../lock/check-with-lock-race.test.ts | 300 ++++++ .../lock/check-with-lock-rollovers.test.ts | 240 +++++ .../check-lock-entity-product.test.ts | 694 ++++++++++++++ .../entities/check-lock-per-entity.test.ts | 641 +++++++++++++ ...heck-with-lock-postgres-edge-cases.test.ts | 399 ++++++++ ...check-with-lock-postgres-rollovers.test.ts | 248 +++++ .../postgres/check-with-lock-postgres.test.ts | 220 +++++ .../balances/track/track-misc.test.ts | 2 +- .../events/expectCustomerEventsCorrect.ts | 24 + .../utils/events/getCustomerEvents.ts | 26 + .../lockUtils/expectLockReceiptDeleted.ts | 23 + .../integration/utils/expectBalanceCorrect.ts | 44 +- server/tsconfig.json | 2 + shared/api/balances/check/checkParams.ts | 8 +- shared/api/balances/check/checkResponseV3.ts | 8 +- shared/api/balances/common/lockParams.ts | 10 +- .../finalizeLock/finalizeLockParamsV0.ts | 4 +- 83 files changed, 6878 insertions(+), 786 deletions(-) create mode 100644 .plans/check-reserve/04_region_cache.md create mode 100644 .zed/settings.json create mode 100644 server/src/_luaScriptsV2/deduction/lock/claimLockReceipt.lua delete mode 100644 server/src/_luaScriptsV2/deduction/lock/lockExpiryUtils.lua delete mode 100644 server/src/_luaScriptsV2/deduction/lock/unwindAndDeduct.lua create mode 100644 server/src/external/aws/eventbridge/eventBridgeUtils.ts create mode 100644 server/src/external/aws/eventbridge/initEventBridge.ts create mode 100644 server/src/external/aws/roles/schedulerPermissionsPolicyDev.json create mode 100644 server/src/external/aws/roles/schedulerPermissionsPolicyProd.json create mode 100644 server/src/external/aws/roles/schedulerTrustPolicy.json create mode 100644 server/src/internal/balances/finalizeLock/buildFinalizeLockContext.ts delete mode 100644 server/src/internal/balances/finalizeLock/executeRedisUnwindAndDeduct.ts create mode 100644 server/src/internal/balances/finalizeLock/expireLock.ts delete mode 100644 server/src/internal/balances/finalizeLock/finalizeLock.ts create mode 100644 server/src/internal/balances/finalizeLock/insertFinalizeLockEvent.ts create mode 100644 server/src/internal/balances/finalizeLock/runFinalizeLock.ts create mode 100644 server/src/internal/balances/finalizeLock/runPostgresFinalizeLock.ts create mode 100644 server/src/internal/balances/finalizeLock/runRedisFinalizeLock.ts create mode 100644 server/src/internal/balances/utils/lock/cancelLockExpiry.ts create mode 100644 server/src/internal/balances/utils/lock/claimLockReceipt.ts create mode 100644 server/src/internal/balances/utils/lock/deleteLockReceipt.ts create mode 100644 server/tests/integration/balances/lock/basic/check-with-lock-additional-deduct.test.ts create mode 100644 server/tests/integration/balances/lock/basic/check-with-lock-refund-breakdown.test.ts create mode 100644 server/tests/integration/balances/lock/basic/check-with-lock-refund.test.ts create mode 100644 server/tests/integration/balances/lock/basic/check-with-lock-release.test.ts delete mode 100644 server/tests/integration/balances/lock/check-with-lock-basic.test.ts create mode 100644 server/tests/integration/balances/lock/check-with-lock-concurrent-stress.test.ts create mode 100644 server/tests/integration/balances/lock/check-with-lock-credit-system.test.ts create mode 100644 server/tests/integration/balances/lock/check-with-lock-edge-cases.test.ts create mode 100644 server/tests/integration/balances/lock/check-with-lock-errors.test.ts create mode 100644 server/tests/integration/balances/lock/check-with-lock-expiry.test.ts delete mode 100644 server/tests/integration/balances/lock/check-with-lock-postgres.test.ts create mode 100644 server/tests/integration/balances/lock/check-with-lock-race.test.ts create mode 100644 server/tests/integration/balances/lock/check-with-lock-rollovers.test.ts create mode 100644 server/tests/integration/balances/lock/entities/check-lock-entity-product.test.ts create mode 100644 server/tests/integration/balances/lock/entities/check-lock-per-entity.test.ts create mode 100644 server/tests/integration/balances/lock/postgres/check-with-lock-postgres-edge-cases.test.ts create mode 100644 server/tests/integration/balances/lock/postgres/check-with-lock-postgres-rollovers.test.ts create mode 100644 server/tests/integration/balances/lock/postgres/check-with-lock-postgres.test.ts create mode 100644 server/tests/integration/balances/utils/events/expectCustomerEventsCorrect.ts create mode 100644 server/tests/integration/balances/utils/events/getCustomerEvents.ts create mode 100644 server/tests/integration/balances/utils/lockUtils/expectLockReceiptDeleted.ts diff --git a/.gitignore b/.gitignore index 44b3a3b06..15b631b83 100644 --- a/.gitignore +++ b/.gitignore @@ -124,3 +124,5 @@ server/autumn.config.ts # Speakeasy others/python-sdk/docs packages/sdk/docs + +TAKEHOME.md diff --git a/.plans/check-reserve/04_region_cache.md b/.plans/check-reserve/04_region_cache.md new file mode 100644 index 000000000..1800aedcf --- /dev/null +++ b/.plans/check-reserve/04_region_cache.md @@ -0,0 +1 @@ +Now, the other thing we need to thnk about is expireLock and finalizeLock happening on different Redis instances. The issue is that our workers where expireLock runs on lives in us-west, and the API server where finalizeLock runs on is in us-east. So technically, we could have these two running concurrently and we need to handle it. Any operations on Redis will be merged in Redis according to the following guide: [Pasted ~1 lines] Please read this carefully and think about the cases we need to handle. \ No newline at end of file diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 000000000..ab4cae016 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,56 @@ +{ + "format_on_save": "on", + "file_scan_inclusions": [".env*"], + "lsp": { + "biome": { + "binary": { + "path": "node_modules/.bin/biome", + "arguments": ["lsp-proxy"] + }, + "settings": { + "require_config_file": true + } + } + }, + "languages": { + "TypeScript": { + "language_servers": ["tsgo", "biome", "!vtsls", "!typescript-language-server", "..."], + "formatter": { "language_server": { "name": "biome" } }, + "prettier": { "allowed": false }, + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true + } + }, + "TSX": { + "language_servers": ["tsgo", "biome", "!vtsls", "!typescript-language-server", "..."], + "formatter": { "language_server": { "name": "biome" } }, + "prettier": { "allowed": false }, + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true + } + }, + "JavaScript": { + "language_servers": ["tsgo", "biome", "!vtsls", "!typescript-language-server", "..."], + "formatter": { "language_server": { "name": "biome" } }, + "prettier": { "allowed": false }, + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true + } + }, + "JSON": { + "language_servers": ["biome", "..."], + "formatter": { "language_server": { "name": "biome" } } + }, + "JSONC": { + "language_servers": ["biome", "..."], + "formatter": { "language_server": { "name": "biome" } } + }, + "CSS": { + "language_servers": ["biome", "..."], + "formatter": { "language_server": { "name": "biome" } } + } + } +} diff --git a/bun.lock b/bun.lock index aac5d180f..85bc29ed9 100644 --- a/bun.lock +++ b/bun.lock @@ -201,6 +201,7 @@ "@anthropic-ai/sdk": "^0.32.1", "@autumn/ksuid": "workspace:*", "@autumn/shared": "workspace:*", + "@aws-sdk/client-scheduler": "^3.1004.0", "@aws-sdk/client-sqs": "^3.958.0", "@axiomhq/pino": "^1.3.1", "@better-auth/dash": "catalog:", @@ -525,6 +526,8 @@ "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.600.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/client-sso-oidc": "3.600.0", "@aws-sdk/client-sts": "3.600.0", "@aws-sdk/core": "3.598.0", "@aws-sdk/credential-provider-node": "3.600.0", "@aws-sdk/middleware-host-header": "3.598.0", "@aws-sdk/middleware-logger": "3.598.0", "@aws-sdk/middleware-recursion-detection": "3.598.0", "@aws-sdk/middleware-user-agent": "3.598.0", "@aws-sdk/region-config-resolver": "3.598.0", "@aws-sdk/types": "3.598.0", "@aws-sdk/util-endpoints": "3.598.0", "@aws-sdk/util-user-agent-browser": "3.598.0", "@aws-sdk/util-user-agent-node": "3.598.0", "@smithy/config-resolver": "^3.0.2", "@smithy/core": "^2.2.1", "@smithy/fetch-http-handler": "^3.0.2", "@smithy/hash-node": "^3.0.1", "@smithy/invalid-dependency": "^3.0.1", "@smithy/middleware-content-length": "^3.0.1", "@smithy/middleware-endpoint": "^3.0.2", "@smithy/middleware-retry": "^3.0.4", "@smithy/middleware-serde": "^3.0.1", "@smithy/middleware-stack": "^3.0.1", "@smithy/node-config-provider": "^3.1.1", "@smithy/node-http-handler": "^3.0.1", "@smithy/protocol-http": "^4.0.1", "@smithy/smithy-client": "^3.1.2", "@smithy/types": "^3.1.0", "@smithy/url-parser": "^3.0.1", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", "@smithy/util-defaults-mode-browser": "^3.0.4", "@smithy/util-defaults-mode-node": "^3.0.4", "@smithy/util-endpoints": "^2.0.2", "@smithy/util-middleware": "^3.0.1", "@smithy/util-retry": "^3.0.1", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-8dYsnDLiD0rjujRiZZl0E57heUkHqMSFZHBi0YMs57SM8ODPxK3tahwDYZtS7bqanvFKZwGy+o9jIcij7jBOlA=="], + "@aws-sdk/client-scheduler": ["@aws-sdk/client-scheduler@3.1004.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.18", "@aws-sdk/credential-provider-node": "^3.972.18", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.19", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.4", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.8", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.22", "@smithy/middleware-retry": "^4.4.39", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.38", "@smithy/util-defaults-mode-node": "^4.2.41", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-isT8SVA4TNKHNha1Rt75OXlBXF1tTwu1zAKfqKK1+AqxtfSntVbOagiDaquGTchieWsTzR9WCjiXTQMk9T7vug=="], + "@aws-sdk/client-sqs": ["@aws-sdk/client-sqs@3.1001.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.16", "@aws-sdk/credential-provider-node": "^3.972.15", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-sdk-sqs": "^3.972.12", "@aws-sdk/middleware-user-agent": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.1", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.7", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/md5-js": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.21", "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.37", "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-q5lUCFJwwIb2Qn8nHrTptJpIjyx8wmWpm0VRhzxvx4idUNWmp5g1QsexD3qMWYvDB++C8EO9cvtNytMnN+ukQA=="], "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.598.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.598.0", "@aws-sdk/middleware-host-header": "3.598.0", "@aws-sdk/middleware-logger": "3.598.0", "@aws-sdk/middleware-recursion-detection": "3.598.0", "@aws-sdk/middleware-user-agent": "3.598.0", "@aws-sdk/region-config-resolver": "3.598.0", "@aws-sdk/types": "3.598.0", "@aws-sdk/util-endpoints": "3.598.0", "@aws-sdk/util-user-agent-browser": "3.598.0", "@aws-sdk/util-user-agent-node": "3.598.0", "@smithy/config-resolver": "^3.0.2", "@smithy/core": "^2.2.1", "@smithy/fetch-http-handler": "^3.0.2", "@smithy/hash-node": "^3.0.1", "@smithy/invalid-dependency": "^3.0.1", "@smithy/middleware-content-length": "^3.0.1", "@smithy/middleware-endpoint": "^3.0.2", "@smithy/middleware-retry": "^3.0.4", "@smithy/middleware-serde": "^3.0.1", "@smithy/middleware-stack": "^3.0.1", "@smithy/node-config-provider": "^3.1.1", "@smithy/node-http-handler": "^3.0.1", "@smithy/protocol-http": "^4.0.1", "@smithy/smithy-client": "^3.1.2", "@smithy/types": "^3.1.0", "@smithy/url-parser": "^3.0.1", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", "@smithy/util-defaults-mode-browser": "^3.0.4", "@smithy/util-defaults-mode-node": "^3.0.4", "@smithy/util-endpoints": "^2.0.2", "@smithy/util-middleware": "^3.0.1", "@smithy/util-retry": "^3.0.1", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-nOI5lqPYa+YZlrrzwAJywJSw3MKVjvu6Ge2fCqQUNYMfxFB0NAaDFnl0EPjXi+sEbtCuz/uWE77poHbqiZ+7Iw=="], @@ -4665,6 +4668,74 @@ "@aws-sdk/client-cognito-identity/@smithy/util-utf8": ["@smithy/util-utf8@3.0.0", "", { "dependencies": { "@smithy/util-buffer-from": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA=="], + "@aws-sdk/client-scheduler/@aws-sdk/core": ["@aws-sdk/core@3.973.18", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.8", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-GUIlegfcK2LO1J2Y98sCJy63rQSiLiDOgVw7HiHPRqfI2vb3XozTVqemwO0VSGXp54ngCnAQz0Lf0YPCBINNxA=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.18", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.16", "@aws-sdk/credential-provider-http": "^3.972.18", "@aws-sdk/credential-provider-ini": "^3.972.17", "@aws-sdk/credential-provider-process": "^3.972.16", "@aws-sdk/credential-provider-sso": "^3.972.17", "@aws-sdk/credential-provider-web-identity": "^3.972.17", "@aws-sdk/types": "^3.973.5", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-ZDJa2gd1xiPg/nBDGhUlat02O8obaDEnICBAVS8qieZ0+nDfaB0Z3ec6gjZj27OqFTjnB/Q5a0GwQwb7rMVViw=="], + + "@aws-sdk/client-scheduler/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ=="], + + "@aws-sdk/client-scheduler/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w=="], + + "@aws-sdk/client-scheduler/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ=="], + + "@aws-sdk/client-scheduler/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@smithy/core": "^3.23.8", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-retry": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-Km90fcXt3W/iqujHzuM6IaDkYCj73gsYufcuWXApWdzoTy6KGk8fnchAjePMARU0xegIR3K4N3yIo1vy7OVe8A=="], + + "@aws-sdk/client-scheduler/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA=="], + + "@aws-sdk/client-scheduler/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-sdk/client-scheduler/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.4", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" } }, "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA=="], + + "@aws-sdk/client-scheduler/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw=="], + + "@aws-sdk/client-scheduler/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.4", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.19", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-uqKeLqZ9D3nQjH7HGIERNXK9qnSpUK08l4MlJ5/NZqSSdeJsVANYp437EM9sEzwU28c2xfj2V6qlkqzsgtKs6Q=="], + + "@aws-sdk/client-scheduler/@smithy/core": ["@smithy/core@3.23.9", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.12", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-stream": "^4.5.17", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-1Vcut4LEL9HZsdpI0vFiRYIsaoPwZLjAxnVQDUMQK8beMS+EYPLDQCXtbzfxmM5GzSgjfe2Q9M7WaXwIMQllyQ=="], + + "@aws-sdk/client-scheduler/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.13", "", { "dependencies": { "@smithy/protocol-http": "^5.3.11", "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ=="], + + "@aws-sdk/client-scheduler/@smithy/hash-node": ["@smithy/hash-node@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A=="], + + "@aws-sdk/client-scheduler/@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g=="], + + "@aws-sdk/client-scheduler/@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.11", "", { "dependencies": { "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw=="], + + "@aws-sdk/client-scheduler/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.23", "", { "dependencies": { "@smithy/core": "^3.23.9", "@smithy/middleware-serde": "^4.2.12", "@smithy/node-config-provider": "^4.3.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-UEFIejZy54T1EJn2aWJ45voB7RP2T+IRzUqocIdM6GFFa5ClZncakYJfcYnoXt3UsQrZZ9ZRauGm77l9UCbBLw=="], + + "@aws-sdk/client-scheduler/@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.40", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/protocol-http": "^5.3.11", "@smithy/service-error-classification": "^4.2.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-YhEMakG1Ae57FajERdHNZ4ShOPIY7DsgV+ZoAxo/5BT0KIe+f6DDU2rtIymNNFIj22NJfeeI6LWIifrwM0f+rA=="], + + "@aws-sdk/client-scheduler/@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng=="], + + "@aws-sdk/client-scheduler/@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg=="], + + "@aws-sdk/client-scheduler/@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.11", "", { "dependencies": { "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg=="], + + "@aws-sdk/client-scheduler/@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.14", "", { "dependencies": { "@smithy/abort-controller": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A=="], + + "@aws-sdk/client-scheduler/@smithy/protocol-http": ["@smithy/protocol-http@5.3.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ=="], + + "@aws-sdk/client-scheduler/@smithy/smithy-client": ["@smithy/smithy-client@4.12.3", "", { "dependencies": { "@smithy/core": "^3.23.9", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-stack": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" } }, "sha512-7k4UxjSpHmPN2AxVhvIazRSzFQjWnud3sOsXcFStzagww17j1cFQYqTSiQ8xuYK3vKLR1Ni8FzuT3VlKr3xCNw=="], + + "@aws-sdk/client-scheduler/@smithy/url-parser": ["@smithy/url-parser@4.2.11", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing=="], + + "@aws-sdk/client-scheduler/@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="], + + "@aws-sdk/client-scheduler/@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="], + + "@aws-sdk/client-scheduler/@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="], + + "@aws-sdk/client-scheduler/@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.39", "", { "dependencies": { "@smithy/property-provider": "^4.2.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-ui7/Ho/+VHqS7Km2wBw4/Ab4RktoiSshgcgpJzC4keFPs6tLJS4IQwbeahxQS3E/w98uq6E1mirCH/id9xIXeQ=="], + + "@aws-sdk/client-scheduler/@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.42", "", { "dependencies": { "@smithy/config-resolver": "^4.4.10", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-QDA84CWNe8Akpj15ofLO+1N3Rfg8qa2K5uX0y6HnOp4AnRYRgWrKx/xzbYNbVF9ZsyJUYOfcoaN3y93wA/QJ2A=="], + + "@aws-sdk/client-scheduler/@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.2", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA=="], + + "@aws-sdk/client-scheduler/@smithy/util-middleware": ["@smithy/util-middleware@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw=="], + + "@aws-sdk/client-scheduler/@smithy/util-retry": ["@smithy/util-retry@4.2.11", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw=="], + + "@aws-sdk/client-scheduler/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + "@aws-sdk/client-sso/@aws-sdk/core": ["@aws-sdk/core@3.598.0", "", { "dependencies": { "@smithy/core": "^2.2.1", "@smithy/protocol-http": "^4.0.1", "@smithy/signature-v4": "^3.1.0", "@smithy/smithy-client": "^3.1.2", "@smithy/types": "^3.1.0", "fast-xml-parser": "4.2.5", "tslib": "^2.6.2" } }, "sha512-HaSjt7puO5Cc7cOlrXFCW0rtA0BM9lvzjl56x0A20Pt+0wxXGeTOZZOkXQIepbrFkV2e/HYukuT9e99vXDm59g=="], "@aws-sdk/client-sso/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.598.0", "", { "dependencies": { "@aws-sdk/types": "3.598.0", "@smithy/protocol-http": "^4.0.1", "@smithy/types": "^3.1.0", "tslib": "^2.6.2" } }, "sha512-WiaG059YBQwQraNejLIi0gMNkX7dfPZ8hDIhvMr5aVPRbaHH8AYF3iNSsXYCHvA2Cfa1O9haYXsuMF9flXnCmA=="], @@ -5877,6 +5948,68 @@ "@aws-sdk/client-cognito-identity/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@3.0.0", "", { "dependencies": { "@smithy/is-array-buffer": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA=="], + "@aws-sdk/client-scheduler/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="], + + "@aws-sdk/client-scheduler/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg=="], + + "@aws-sdk/client-scheduler/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.3.11", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.16", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-HrdtnadvTGAQUr18sPzGlE5El3ICphnH6SU7UQOMOWFgRKbTRNN8msTxM4emzguUso9CzaHU2xy5ctSrmK5YNA=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.18", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/types": "^3.973.5", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/node-http-handler": "^4.4.14", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" } }, "sha512-NyB6smuZAixND5jZumkpkunQ0voc4Mwgkd+SZ6cvAzIB7gK8HV8Zd4rS8Kn5MmoGgusyNfVGG+RLoYc4yFiw+A=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/credential-provider-env": "^3.972.16", "@aws-sdk/credential-provider-http": "^3.972.18", "@aws-sdk/credential-provider-login": "^3.972.17", "@aws-sdk/credential-provider-process": "^3.972.16", "@aws-sdk/credential-provider-sso": "^3.972.17", "@aws-sdk/credential-provider-web-identity": "^3.972.17", "@aws-sdk/nested-clients": "^3.996.7", "@aws-sdk/types": "^3.973.5", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-dFqh7nfX43B8dO1aPQHOcjC0SnCJ83H3F+1LoCh3X1P7E7N09I+0/taID0asU6GCddfDExqnEvQtDdkuMe5tKQ=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.16", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-n89ibATwnLEg0ZdZmUds5bq8AfBAdoYEDpqP3uzPLaRuGelsKlIvCYSNNvfgGLi8NaHPNNhs1HjJZYbqkW9b+g=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/nested-clients": "^3.996.7", "@aws-sdk/token-providers": "3.1004.0", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-wGtte+48xnhnhHMl/MsxzacBPs5A+7JJedjiP452IkHY7vsbYKcvQBqFye8LwdTJVeHtBHv+JFeTscnwepoWGg=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/nested-clients": "^3.996.7", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-8aiVJh6fTdl8gcyL+sVNcNwTtWpmoFa1Sh7xlj6Z7L/cZ/tYMEBHq44wTYG8Kt0z/PpGNopD89nbj3FHl9QmTA=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.11", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@smithy/property-provider": ["@smithy/property-provider@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.6", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw=="], + + "@aws-sdk/client-scheduler/@smithy/core/@smithy/util-stream": ["@smithy/util-stream@4.5.17", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.13", "@smithy/node-http-handler": "^4.4.14", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ=="], + + "@aws-sdk/client-scheduler/@smithy/core/@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="], + + "@aws-sdk/client-scheduler/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA=="], + + "@aws-sdk/client-scheduler/@smithy/hash-node/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], + + "@aws-sdk/client-scheduler/@smithy/middleware-endpoint/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.6", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw=="], + + "@aws-sdk/client-scheduler/@smithy/middleware-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0" } }, "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw=="], + + "@aws-sdk/client-scheduler/@smithy/middleware-retry/@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="], + + "@aws-sdk/client-scheduler/@smithy/node-config-provider/@smithy/property-provider": ["@smithy/property-provider@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg=="], + + "@aws-sdk/client-scheduler/@smithy/node-config-provider/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.6", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw=="], + + "@aws-sdk/client-scheduler/@smithy/node-http-handler/@smithy/abort-controller": ["@smithy/abort-controller@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ=="], + + "@aws-sdk/client-scheduler/@smithy/node-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA=="], + + "@aws-sdk/client-scheduler/@smithy/smithy-client/@smithy/util-stream": ["@smithy/util-stream@4.5.17", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.13", "@smithy/node-http-handler": "^4.4.14", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ=="], + + "@aws-sdk/client-scheduler/@smithy/url-parser/@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ=="], + + "@aws-sdk/client-scheduler/@smithy/util-base64/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], + + "@aws-sdk/client-scheduler/@smithy/util-defaults-mode-browser/@smithy/property-provider": ["@smithy/property-provider@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg=="], + + "@aws-sdk/client-scheduler/@smithy/util-defaults-mode-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.11", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g=="], + + "@aws-sdk/client-scheduler/@smithy/util-defaults-mode-node/@smithy/property-provider": ["@smithy/property-provider@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg=="], + + "@aws-sdk/client-scheduler/@smithy/util-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0" } }, "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw=="], + + "@aws-sdk/client-scheduler/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], + "@aws-sdk/client-sso-oidc/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@3.1.2", "", { "dependencies": { "@smithy/is-array-buffer": "^3.0.0", "@smithy/types": "^3.3.0", "@smithy/util-hex-encoding": "^3.0.0", "@smithy/util-middleware": "^3.0.3", "@smithy/util-uri-escape": "^3.0.0", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-3BcPylEsYtD0esM4Hoyml/+s7WP2LFhcM3J2AGdcL2vx9O60TtfpDOL72gjb4lU8NeRPeKAwR77YNyyGvMbuEA=="], "@aws-sdk/client-sso-oidc/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.598.0", "", { "dependencies": { "@aws-sdk/types": "3.598.0", "@smithy/property-provider": "^3.1.1", "@smithy/types": "^3.1.0", "tslib": "^2.6.2" } }, "sha512-vi1khgn7yXzLCcgSIzQrrtd2ilUM0dWodxj3PQ6BLfP0O+q1imO3hG1nq7DVyJtq7rFHs6+9N8G4mYvTkxby2w=="], @@ -6677,6 +6810,42 @@ "@aws-sdk/client-cognito-identity/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="], + "@aws-sdk/client-scheduler/@aws-sdk/core/@smithy/signature-v4/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], + + "@aws-sdk/client-scheduler/@aws-sdk/core/@smithy/signature-v4/@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="], + + "@aws-sdk/client-scheduler/@aws-sdk/core/@smithy/signature-v4/@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream": ["@smithy/util-stream@4.5.17", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.13", "@smithy/node-http-handler": "^4.4.14", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/nested-clients": "^3.996.7", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-gf2E5b7LpKb+JX2oQsRIDxdRZjBFZt2olCGlWCdb3vBERbXIPgm2t1R5mEnwd4j0UEO/Tbg5zN2KJbHXttJqwA=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.7", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.18", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.19", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.4", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.8", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.22", "@smithy/middleware-retry": "^4.4.39", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.38", "@smithy/util-defaults-mode-node": "^4.2.41", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-MlGWA8uPaOs5AiTZ5JLM4uuWDm9EEAnm9cqwvqQIc6kEgel/8s1BaOWm9QgUcfc9K8qd7KkC3n43yDbeXOA2tg=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.7", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.18", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.19", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.4", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.8", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.22", "@smithy/middleware-retry": "^4.4.39", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.38", "@smithy/util-defaults-mode-node": "^4.2.41", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-MlGWA8uPaOs5AiTZ5JLM4uuWDm9EEAnm9cqwvqQIc6kEgel/8s1BaOWm9QgUcfc9K8qd7KkC3n43yDbeXOA2tg=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1004.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/nested-clients": "^3.996.7", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.7", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.18", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.19", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.4", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.8", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.22", "@smithy/middleware-retry": "^4.4.39", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.38", "@smithy/util-defaults-mode-node": "^4.2.41", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-MlGWA8uPaOs5AiTZ5JLM4uuWDm9EEAnm9cqwvqQIc6kEgel/8s1BaOWm9QgUcfc9K8qd7KkC3n43yDbeXOA2tg=="], + + "@aws-sdk/client-scheduler/@smithy/core/@smithy/util-stream/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], + + "@aws-sdk/client-scheduler/@smithy/core/@smithy/util-stream/@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="], + + "@aws-sdk/client-scheduler/@smithy/fetch-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="], + + "@aws-sdk/client-scheduler/@smithy/hash-node/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], + + "@aws-sdk/client-scheduler/@smithy/node-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="], + + "@aws-sdk/client-scheduler/@smithy/smithy-client/@smithy/util-stream/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], + + "@aws-sdk/client-scheduler/@smithy/smithy-client/@smithy/util-stream/@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="], + + "@aws-sdk/client-scheduler/@smithy/util-base64/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], + + "@aws-sdk/client-scheduler/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], + "@aws-sdk/client-sso-oidc/@aws-sdk/core/@smithy/signature-v4/@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="], "@aws-sdk/client-sso-oidc/@aws-sdk/core/@smithy/signature-v4/@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ=="], @@ -6957,6 +7126,14 @@ "@aws-sdk/client-cognito-identity/@smithy/smithy-client/@smithy/util-stream/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="], + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], + + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="], + + "@aws-sdk/client-scheduler/@smithy/core/@smithy/util-stream/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], + + "@aws-sdk/client-scheduler/@smithy/smithy-client/@smithy/util-stream/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], + "@aws-sdk/client-sso-oidc/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@4.1.3", "", { "dependencies": { "@smithy/protocol-http": "^4.1.8", "@smithy/querystring-builder": "^3.0.11", "@smithy/types": "^3.7.2", "@smithy/util-base64": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-6SxNltSncI8s689nvnzZQc/dPXcpHQ34KUj6gR/HBroytKOd/isMG3gJF/zBE1TBmTT18TXyzhg3O3SOOqGEhA=="], "@aws-sdk/client-sso-oidc/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-buffer-from": ["@smithy/util-buffer-from@3.0.0", "", { "dependencies": { "@smithy/is-array-buffer": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA=="], @@ -7073,6 +7250,8 @@ "@aws-sdk/client-cognito-identity/@smithy/smithy-client/@smithy/util-stream/@smithy/fetch-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg=="], + "@aws-sdk/client-scheduler/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], + "@aws-sdk/client-sso-oidc/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@3.0.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "@smithy/util-uri-escape": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-u+5HV/9uJaeLj5XTb6+IEF/dokWWkEqJ0XiaRRogyREmKGUgZnNecLucADLdauWFKUNbQfulHFEZEdjwEBjXRg=="], "@aws-sdk/client-sso-oidc/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="], diff --git a/server/package.json b/server/package.json index 972a1e8ea..3e2e27be2 100644 --- a/server/package.json +++ b/server/package.json @@ -40,6 +40,7 @@ "@anthropic-ai/sdk": "^0.32.1", "@autumn/ksuid": "workspace:*", "@autumn/shared": "workspace:*", + "@aws-sdk/client-scheduler": "^3.1004.0", "@aws-sdk/client-sqs": "^3.958.0", "@axiomhq/pino": "^1.3.1", "@better-auth/dash": "catalog:", diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua index c0260aa32..630a1ee7e 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua @@ -67,15 +67,17 @@ local lock_receipt_key = params.lock_receipt_key local overage_behavior_is_allow = alter_granted_balance or overage_behaviour == 'allow' -- Check if customer exists (just check the key exists) +local empty_logs = cjson.decode('[]') + local key_exists = redis.call('EXISTS', cache_key) if key_exists == 0 then - return cjson.encode({ error = 'CUSTOMER_NOT_FOUND', updates = {}, remaining = 0 }) + return cjson.encode({ error = 'CUSTOMER_NOT_FOUND', updates = {}, rollover_updates = {}, mutation_logs = empty_logs, remaining = 0 }) end -- Get FullCustomer structure (for finding entitlement indices only) local full_customer_json = redis.call('JSON.GET', cache_key, '.') if not full_customer_json then - return cjson.encode({ error = 'CUSTOMER_NOT_FOUND', updates = {}, remaining = 0 }) + return cjson.encode({ error = 'CUSTOMER_NOT_FOUND', updates = {}, rollover_updates = {}, mutation_logs = empty_logs, remaining = 0 }) end local full_customer = cjson.decode(full_customer_json) @@ -84,6 +86,8 @@ if not full_customer.customer_products then return cjson.encode({ error = 'NO_CUSTOMER_PRODUCTS', updates = {}, + rollover_updates = {}, + mutation_logs = empty_logs, remaining = 0 }) end @@ -101,6 +105,8 @@ local context = init_context({ full_customer = full_customer, }) +local unwind_modified_cus_ent_ids = {} + if not is_nil(unwind_value) and safe_number(unwind_value) > 0 then local unwind_result = unwind_lock_on_context({ context = context, @@ -118,6 +124,16 @@ if not is_nil(unwind_value) and safe_number(unwind_value) > 0 then logs = context.logs, }) end + + -- Track which entitlements the unwind touched so the caller can sync them. + unwind_modified_cus_ent_ids = unwind_result.modified_customer_entitlement_ids or {} + + -- Fold any skipped unwind (missing entitlements/rollovers) into amount_to_deduct + -- so the forward pass compensates against current live entitlements. + local skipped = unwind_result.remaining_signed_unwind_value or 0 + if skipped ~= 0 then + amount_to_deduct = safe_number(amount_to_deduct or 0) + skipped + end end local logger = context.logger @@ -143,10 +159,27 @@ local updates = deduction_result.updates local rollover_updates = deduction_result.rollover_updates local remaining_amount = deduction_result.remaining_amount +-- Inject unwind-only touched entitlements into updates so TypeScript can +-- sync their new balances. The forward deduction may not have touched them. +for _, cus_ent_id in ipairs(unwind_modified_cus_ent_ids) do + if is_nil(updates[cus_ent_id]) then + local ent_data = context.customer_entitlements[cus_ent_id] + if ent_data then + updates[cus_ent_id] = { + balance = ent_data.balance or 0, + additional_balance = 0, + adjustment = ent_data.adjustment or 0, + entities = ent_data.entities or {}, + deducted = 0, + } + end + end +end + logger.log(" remaining_amount: %s", tostring(remaining_amount or "nil")) logger.log(" is_refund: %s", tostring(remaining_amount < 0 or false)) local mutation_logs = context.mutation_logs -if #mutation_logs == 0 then +if type(mutation_logs) ~= 'table' or #mutation_logs == 0 then mutation_logs = cjson.decode('[]') end -- Throw error and don't apply updates if we're in reject mode and there's still remaining amount @@ -179,6 +212,7 @@ then created_at = lock.created_at or cjson.null, }, mutation_logs = mutation_logs, + ttl_at = lock.ttl_at or cjson.null, }) end diff --git a/server/src/_luaScriptsV2/deduction/lock/claimLockReceipt.lua b/server/src/_luaScriptsV2/deduction/lock/claimLockReceipt.lua new file mode 100644 index 000000000..8b4add99a --- /dev/null +++ b/server/src/_luaScriptsV2/deduction/lock/claimLockReceipt.lua @@ -0,0 +1,22 @@ +-- ============================================================================ +-- CLAIM LOCK RECEIPT +-- Atomically transitions a lock receipt from 'pending' -> 'processing'. +-- +-- KEYS[1]: lock_receipt_key +-- +-- Returns nil on success (claim granted). +-- Returns an error code string if the receipt is not claimable. +-- ============================================================================ + +local lock_receipt_key = KEYS[1] + +local receipt = load_lock_receipt(lock_receipt_key) + +local err = require_pending_receipt(receipt) +if err ~= nil then + return err +end + +redis.call('JSON.SET', lock_receipt_key, '$.status', '"processing"') + +return 'OK' diff --git a/server/src/_luaScriptsV2/deduction/lock/lockExpiryUtils.lua b/server/src/_luaScriptsV2/deduction/lock/lockExpiryUtils.lua deleted file mode 100644 index 8d574e543..000000000 --- a/server/src/_luaScriptsV2/deduction/lock/lockExpiryUtils.lua +++ /dev/null @@ -1,23 +0,0 @@ --- ============================================================================ --- LOCK EXPIRY HELPERS --- Helpers for indexing lock expiries in a Redis sorted set --- ============================================================================ - --- ============================================================================ --- HELPER: Add lock to expiry index --- No-op when expires_at_ms is nil/null. --- ============================================================================ -local function add_lock_expiry(expiry_zset_key, lock_receipt_key, expires_at_ms) - if is_nil(expires_at_ms) then - return 0 - end - - return redis.call('ZADD', expiry_zset_key, tostring(expires_at_ms), lock_receipt_key) -end - --- ============================================================================ --- HELPER: Remove lock from expiry index --- ============================================================================ -local function remove_lock_expiry(expiry_zset_key, lock_receipt_key) - return redis.call('ZREM', expiry_zset_key, lock_receipt_key) -end diff --git a/server/src/_luaScriptsV2/deduction/lock/lockReceipt.lua b/server/src/_luaScriptsV2/deduction/lock/lockReceipt.lua index cc2fc2d24..e292f5008 100644 --- a/server/src/_luaScriptsV2/deduction/lock/lockReceipt.lua +++ b/server/src/_luaScriptsV2/deduction/lock/lockReceipt.lua @@ -49,11 +49,18 @@ end -- lock_receipt_key: string -- receipt: table (base receipt metadata to persist) -- mutation_logs: table | nil +-- ttl_at: number | nil (Unix seconds for EXPIREAT) -- ============================================================================ local function save_lock_receipt_from_updates(params) local receipt = params.receipt or {} local mutation_logs = params.mutation_logs or {} receipt.items = #mutation_logs > 0 and mutation_logs or cjson.decode('[]') - return store_lock_receipt(params.lock_receipt_key, receipt) + store_lock_receipt(params.lock_receipt_key, receipt) + + if not is_nil(params.ttl_at) then + redis.call('EXPIREAT', params.lock_receipt_key, params.ttl_at) + end + + return receipt end diff --git a/server/src/_luaScriptsV2/deduction/lock/lockStateUtils.lua b/server/src/_luaScriptsV2/deduction/lock/lockStateUtils.lua index 9bcd29710..dc051c5f0 100644 --- a/server/src/_luaScriptsV2/deduction/lock/lockStateUtils.lua +++ b/server/src/_luaScriptsV2/deduction/lock/lockStateUtils.lua @@ -1,59 +1,11 @@ -- ============================================================================ -- RESERVATION STATE HELPERS --- Helpers for guarding and transitioning lock receipt state +-- Helpers for guarding lock receipt state transitions. +-- Only two valid statuses: 'pending' (default) and 'processing' (claimed). -- ============================================================================ -local RESERVATION_STATUS_PENDING = 'pending' -local RESERVATION_STATUS_CONFIRMED = 'confirmed' -local RESERVATION_STATUS_RELEASED = 'released' -local RESERVATION_STATUS_EXPIRED = 'expired' - -- ============================================================================ --- HELPER: Read normalized receipt status --- Defaults to pending if status is absent to ease early migrations. --- ============================================================================ -local function get_reservation_status(receipt) - if is_nil(receipt) or is_nil(receipt.status) then - return RESERVATION_STATUS_PENDING - end - - return receipt.status -end - --- ============================================================================ --- GUARDS --- ============================================================================ - -local function is_pending_receipt(receipt) - return get_reservation_status(receipt) == RESERVATION_STATUS_PENDING -end - -local function is_confirmed_receipt(receipt) - return get_reservation_status(receipt) == RESERVATION_STATUS_CONFIRMED -end - -local function is_released_receipt(receipt) - return get_reservation_status(receipt) == RESERVATION_STATUS_RELEASED -end - -local function is_expired_receipt(receipt) - return get_reservation_status(receipt) == RESERVATION_STATUS_EXPIRED -end - -local function is_terminal_receipt(receipt) - return is_confirmed_receipt(receipt) or is_released_receipt(receipt) or is_expired_receipt(receipt) -end - --- ============================================================================ --- HELPER: Mutate receipt status in memory --- ============================================================================ -local function set_reservation_status(receipt, status) - receipt.status = status - return receipt -end - --- ============================================================================ --- HELPER: Enforce pending state +-- HELPER: Enforce pending state (for claim guard) -- Returns nil when valid, or an error code string when invalid. -- ============================================================================ local function require_pending_receipt(receipt) @@ -61,22 +13,31 @@ local function require_pending_receipt(receipt) return 'RESERVATION_NOT_FOUND' end - local status = get_reservation_status(receipt) - if status == RESERVATION_STATUS_PENDING then + local status = receipt.status + if is_nil(status) or status == 'pending' then return nil end - if status == RESERVATION_STATUS_CONFIRMED then - return 'RESERVATION_ALREADY_CONFIRMED' - end - - if status == RESERVATION_STATUS_RELEASED then - return 'RESERVATION_ALREADY_RELEASED' - end - - if status == RESERVATION_STATUS_EXPIRED then - return 'RESERVATION_ALREADY_EXPIRED' + if status == 'processing' then + return 'RESERVATION_ALREADY_PROCESSING' end return 'INVALID_RESERVATION_STATUS' end + +-- ============================================================================ +-- HELPER: Enforce processing state (for callers that already claimed the receipt) +-- Returns nil when valid, or an error code string when invalid. +-- ============================================================================ +local function require_processing_receipt(receipt) + if is_nil(receipt) then + return 'RESERVATION_NOT_FOUND' + end + + local status = receipt.status + if status == 'processing' then + return nil + end + + return 'RESERVATION_NOT_CLAIMED' +end diff --git a/server/src/_luaScriptsV2/deduction/lock/unwindAndDeduct.lua b/server/src/_luaScriptsV2/deduction/lock/unwindAndDeduct.lua deleted file mode 100644 index 4138f0e6b..000000000 --- a/server/src/_luaScriptsV2/deduction/lock/unwindAndDeduct.lua +++ /dev/null @@ -1,78 +0,0 @@ --- ============================================================================ --- UNWIND AND DEDUCT --- Reconciles a lock by first unwinding the receipt tail, then optionally --- applying an additional deduction/refund against the live cached customer. --- Returns the same shape as deductFromCustomerEntitlements. --- ============================================================================ - -local params = cjson.decode(ARGV[1]) -local cache_key = params.full_customer_cache_key -local full_customer_json = redis.call('JSON.GET', cache_key, '.') -if is_nil(full_customer_json) then - return cjson.encode({ - error = 'CUSTOMER_NOT_FOUND', - updates = {}, - rollover_updates = {}, - remaining = 0, - mutation_logs = cjson.decode('[]'), - logs = {}, - }) -end - -local context = init_context({ - cache_key = cache_key, - customer_entitlement_ids = params.customer_entitlement_ids or {}, - full_customer = cjson.decode(full_customer_json), -}) - - - -local unwind_result = unwind_lock_on_context({ - context = context, - lock_receipt_key = params.lock_receipt_key, - unwind_value = params.unwind_value or 0, -}) - -context.logger.log("UNWIND AND DEDUCT: unwind_result=%s", cjson.encode(unwind_result)) - -if not is_nil(unwind_result.error) then - return cjson.encode({ - error = unwind_result.error, - updates = {}, - rollover_updates = {}, - remaining = 0, - mutation_logs = context.mutation_logs or cjson.decode('[]'), - logs = context.logs or {}, - }) -end - -local additional_value = params.additional_value or 0 -local deduction_result = { - updates = {}, - rollover_updates = {}, - remaining_amount = 0, -} - -context.logger.log("UNWIND AND DEDUCT: additional_value=%s", additional_value) -if additional_value == 0 then - apply_pending_writes(cache_key, context) -else - deduction_result = run_deduction_on_context({ - context = context, - sorted_entitlements = params.sorted_entitlements or {}, - rollovers = params.rollovers, - amount_to_deduct = params.amount_to_deduct, - target_entity_id = params.target_entity_id, - }) - - apply_pending_writes(cache_key, context) -end - -return cjson.encode({ - error = cjson.null, - updates = deduction_result.updates or {}, - rollover_updates = deduction_result.rollover_updates or {}, - remaining = deduction_result.remaining_amount or 0, - mutation_logs = context.mutation_logs or cjson.decode('[]'), - logs = context.logs or {}, -}) diff --git a/server/src/_luaScriptsV2/deduction/lock/unwindLockUtils.lua b/server/src/_luaScriptsV2/deduction/lock/unwindLockUtils.lua index 3a7d2ab54..5a3b5cda8 100644 --- a/server/src/_luaScriptsV2/deduction/lock/unwindLockUtils.lua +++ b/server/src/_luaScriptsV2/deduction/lock/unwindLockUtils.lua @@ -162,11 +162,14 @@ local function unwind_lock_item_iteration(params) normalize_lock_item_id(item.customer_entitlement_id) local ent_data = context.customer_entitlements[customer_entitlement_id] if not ent_data then + -- Entitlement no longer exists (e.g. product upgraded mid-flight). + -- Skip this item and leave remaining_unwind_value unchanged so the + -- caller can compensate against current live entitlements. return { applied = false, unwind_iteration_value = 0, remaining_unwind_value = remaining_unwind_value, - error = 'LOCK_CUSTOMER_ENTITLEMENT_NOT_FOUND', + error = nil, } end @@ -205,11 +208,13 @@ local function unwind_lock_item_iteration(params) local rollover_id = normalize_lock_item_id(item.rollover_id) local rollover_data = context.rollovers[rollover_id] if not rollover_data then + -- Rollover no longer exists (e.g. expired or removed mid-flight). + -- Skip this item and leave remaining_unwind_value unchanged. return { applied = false, unwind_iteration_value = 0, remaining_unwind_value = remaining_unwind_value, - error = 'LOCK_ROLLOVER_NOT_FOUND', + error = nil, } end @@ -295,6 +300,16 @@ local function unwind_lock_items(params) remaining_unwind_value = remaining_unwind_value, }) + context.logger.log( + "[unwind_lock_items] index=%d cus_ent_id=%s applied=%s unwound=%s remaining=%s error=%s", + index, + tostring(item.customer_entitlement_id or item.rollover_id or "?"), + tostring(result.applied), + tostring(result.unwind_iteration_value), + tostring(result.remaining_unwind_value), + tostring(result.error or "nil") + ) + if not is_nil(result.error) then return { applied = #iterations > 0, @@ -371,13 +386,26 @@ local function unwind_lock_on_context(params) local receipt = load_lock_receipt(lock_receipt_key) - local pending_error = require_pending_receipt(receipt) + local pending_error = require_processing_receipt(receipt) if not is_nil(pending_error) then + context.logger.log("[unwind_lock] receipt not in processing state: %s", pending_error) empty_result.error = pending_error return empty_result end local items = receipt.items or cjson.decode('[]') + context.logger.log("[unwind_lock] unwinding %d items, unwind_value=%s", #items, tostring(unwind_value)) + + -- Compute lock_sign from the sum of value_deltas across all receipt items. + -- unwind_value is always a positive magnitude; the caller needs lock_sign to + -- know the direction of the original deduction so it can compensate for any + -- items that were skipped (entitlement/rollover no longer exists). + local lock_value_sum = 0 + for _, item in ipairs(items) do + lock_value_sum = lock_value_sum + safe_number(item.value_delta) + end + local lock_sign = lock_value_sum >= 0 and 1 or -1 + local unwind_items_result = unwind_lock_items({ context = context, items = items, @@ -385,18 +413,45 @@ local function unwind_lock_on_context(params) }) if not is_nil(unwind_items_result.error) then + context.logger.log("[unwind_lock] unwind error: %s", unwind_items_result.error) empty_result.error = unwind_items_result.error return empty_result end + local skipped_unwind = unwind_items_result.remaining_unwind_value + -- remaining_signed_unwind_value: the signed amount that could not be unwound + -- because the target entitlement/rollover no longer exists. + -- Callers can add this directly to additional_value to compensate: + -- effective_additional = additional_value + remaining_signed_unwind_value + -- A positive lock (deduction) that couldn't be restored → negative signed value + -- (a refund against current entitlements). + -- A negative lock (credit) that couldn't be taken back → positive signed value + -- (a deduction against current entitlements). + local remaining_signed_unwind_value = -lock_sign * skipped_unwind + + if skipped_unwind > 0 then + context.logger.log( + "[unwind_lock] skipped_unwind=%s, lock_sign=%d, remaining_signed_unwind_value=%s", + tostring(skipped_unwind), lock_sign, tostring(remaining_signed_unwind_value) + ) + end + local modified_ids = collect_unwind_modified_ids({ iterations = unwind_items_result.iterations, }) + context.logger.log( + "[unwind_lock] done: applied=%s, remaining=%s, cus_ents=%d, rollovers=%d", + tostring(unwind_items_result.applied), + tostring(unwind_items_result.remaining_unwind_value), + #modified_ids.modified_customer_entitlement_ids, + #modified_ids.modified_rollover_ids + ) + return { error = cjson.null, unwind_value = unwind_value, - remaining_unwind_value = unwind_items_result.remaining_unwind_value, + remaining_signed_unwind_value = remaining_signed_unwind_value, modified_customer_entitlement_ids = modified_ids.modified_customer_entitlement_ids, modified_rollover_ids = modified_ids.modified_rollover_ids, mutation_logs = context.mutation_logs, diff --git a/server/src/_luaScriptsV2/luaScriptsV2.ts b/server/src/_luaScriptsV2/luaScriptsV2.ts index 110b66825..75a1e08a2 100644 --- a/server/src/_luaScriptsV2/luaScriptsV2.ts +++ b/server/src/_luaScriptsV2/luaScriptsV2.ts @@ -97,22 +97,20 @@ ${LOCK_STATE_UTILS} ${LOCK_UNWIND_UTILS} ${mainScript}`; -const unwindAndDeductMainScript = readFileSync( - join(LOCK_DIR, "unwindAndDeduct.lua"), +const claimLockReceiptMainScript = readFileSync( + join(LOCK_DIR, "claimLockReceipt.lua"), "utf-8", ); -export const UNWIND_AND_DEDUCT_SCRIPT = `${LUA_UTILS} -${READ_BALANCES} -${CONTEXT_UTILS} -${GET_TOTAL_BALANCE} -${DEDUCT_FROM_ROLLOVERS} -${DEDUCT_FROM_MAIN_BALANCE} -${RUN_DEDUCTION_ON_CONTEXT} +/** + * Atomically claims a lock receipt by transitioning status: pending → processing. + * KEYS[1]: lock_receipt_key + * Returns nil on success, or an error code string if not claimable. + */ +export const CLAIM_LOCK_RECEIPT_SCRIPT = `${LUA_UTILS} ${LOCK_RECEIPT_UTILS} ${LOCK_STATE_UTILS} -${LOCK_UNWIND_UTILS} -${unwindAndDeductMainScript}`; +${claimLockReceiptMainScript}`; // ============================================================================ // DELETE FULL CUSTOMER CACHE SCRIPTS diff --git a/server/src/external/aws/eventbridge/eventBridgeUtils.ts b/server/src/external/aws/eventbridge/eventBridgeUtils.ts new file mode 100644 index 000000000..8afc29bfa --- /dev/null +++ b/server/src/external/aws/eventbridge/eventBridgeUtils.ts @@ -0,0 +1,84 @@ +import { + CreateScheduleCommand, + DeleteScheduleCommand, + ResourceNotFoundException, +} from "@aws-sdk/client-scheduler"; +import { logger } from "@/external/logtail/logtailUtils.js"; +import { schedulerClient } from "./initEventBridge.js"; + +const SCHEDULE_GROUP = "default"; +const SCHEDULER_ROLE_ARN = process.env.AWS_EVENTBRIDGE_SCHEDULER_ROLE_ARN || ""; + +/** Derives SQS ARN from URL: https://sqs..amazonaws.com// -> arn:aws:sqs::: */ +const getSqsQueueArn = (): string => { + const url = process.env.SQS_QUEUE_URL || ""; + const match = url.match( + /^https:\/\/sqs\.([a-z0-9-]+)\.amazonaws\.com\/(\d+)\/(.+)$/, + ); + if (!match) + throw new Error(`Cannot derive SQS ARN from SQS_QUEUE_URL: ${url}`); + const [, region, accountId, queueName] = match; + return `arn:aws:sqs:${region}:${accountId}:${queueName}`; +}; + +/** Creates a one-shot EventBridge schedule that delivers an SQS message at scheduleAt */ +export const createSchedule = async ({ + scheduleName, + scheduleAt, + sqsMessageBody, + messageGroupId, +}: { + scheduleName: string; + scheduleAt: Date; + sqsMessageBody: string; + messageGroupId: string; +}) => { + // EventBridge at-expression: at(yyyy-mm-ddThh:mm:ss) + const pad = (n: number) => String(n).padStart(2, "0"); + const d = scheduleAt; + const atExpression = `at(${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())})`; + + const sqsArn = getSqsQueueArn(); + logger.info( + `[EventBridge] Creating schedule: name=${scheduleName} arn=${sqsArn} at=${atExpression}`, + ); + + await schedulerClient.send( + new CreateScheduleCommand({ + Name: scheduleName, + GroupName: SCHEDULE_GROUP, + ScheduleExpression: atExpression, + ScheduleExpressionTimezone: "UTC", + FlexibleTimeWindow: { Mode: "OFF" }, + Target: { + Arn: sqsArn, + RoleArn: SCHEDULER_ROLE_ARN, + Input: sqsMessageBody, + SqsParameters: { + MessageGroupId: messageGroupId, + }, + }, + // Auto-delete after firing so schedules don't accumulate + ActionAfterCompletion: "DELETE", + }), + ); +}; + +/** Deletes an EventBridge schedule by name. Silently ignores not-found errors. */ +export const deleteSchedule = async ({ + scheduleName, +}: { + scheduleName: string; +}) => { + try { + await schedulerClient.send( + new DeleteScheduleCommand({ + Name: scheduleName, + GroupName: SCHEDULE_GROUP, + }), + ); + } catch (error) { + if (error instanceof ResourceNotFoundException) return; + throw error; + } +}; diff --git a/server/src/external/aws/eventbridge/initEventBridge.ts b/server/src/external/aws/eventbridge/initEventBridge.ts new file mode 100644 index 000000000..ec30cde79 --- /dev/null +++ b/server/src/external/aws/eventbridge/initEventBridge.ts @@ -0,0 +1,13 @@ +import { SchedulerClient } from "@aws-sdk/client-scheduler"; + +const DEFAULT_AWS_REGION = "us-west-2"; + +const getSchedulerClientConfig = () => ({ + region: process.env.AWS_REGION || DEFAULT_AWS_REGION, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || "", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "", + }, +}); + +export const schedulerClient = new SchedulerClient(getSchedulerClientConfig()); diff --git a/server/src/external/aws/roles/schedulerPermissionsPolicyDev.json b/server/src/external/aws/roles/schedulerPermissionsPolicyDev.json new file mode 100644 index 000000000..770127483 --- /dev/null +++ b/server/src/external/aws/roles/schedulerPermissionsPolicyDev.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "sqs:SendMessage", + "NotResource": "arn:aws:sqs:::" + } + ] +} \ No newline at end of file diff --git a/server/src/external/aws/roles/schedulerPermissionsPolicyProd.json b/server/src/external/aws/roles/schedulerPermissionsPolicyProd.json new file mode 100644 index 000000000..de3197cd9 --- /dev/null +++ b/server/src/external/aws/roles/schedulerPermissionsPolicyProd.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "sqs:SendMessage", + "Resource": "*" + } + ] +} \ No newline at end of file diff --git a/server/src/external/aws/roles/schedulerTrustPolicy.json b/server/src/external/aws/roles/schedulerTrustPolicy.json new file mode 100644 index 000000000..abfc36ba2 --- /dev/null +++ b/server/src/external/aws/roles/schedulerTrustPolicy.json @@ -0,0 +1,12 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "scheduler.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] +} \ No newline at end of file diff --git a/server/src/external/redis/initRedis.ts b/server/src/external/redis/initRedis.ts index 0ca471438..878e3f92c 100644 --- a/server/src/external/redis/initRedis.ts +++ b/server/src/external/redis/initRedis.ts @@ -17,11 +17,11 @@ import { ADJUST_CUSTOMER_ENTITLEMENT_BALANCE_SCRIPT, APPEND_ENTITY_TO_CUSTOMER_SCRIPT, BATCH_DELETE_FULL_CUSTOMER_CACHE_SCRIPT, + CLAIM_LOCK_RECEIPT_SCRIPT, DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT, DELETE_FULL_CUSTOMER_CACHE_SCRIPT, RESET_CUSTOMER_ENTITLEMENTS_SCRIPT, SET_FULL_CUSTOMER_CACHE_SCRIPT, - UNWIND_AND_DEDUCT_SCRIPT, UPDATE_CUSTOMER_DATA_SCRIPT, UPDATE_CUSTOMER_ENTITLEMENTS_SCRIPT, UPDATE_CUSTOMER_PRODUCT_SCRIPT, @@ -172,11 +172,6 @@ const configureRedisInstance = (redisInstance: Redis): Redis => { lua: DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT, }); - redisInstance.defineCommand("unwindAndDeduct", { - numberOfKeys: 0, - lua: UNWIND_AND_DEDUCT_SCRIPT, - }); - redisInstance.defineCommand("deleteFullCustomerCache", { numberOfKeys: 3, lua: DELETE_FULL_CUSTOMER_CACHE_SCRIPT, @@ -227,6 +222,11 @@ const configureRedisInstance = (redisInstance: Redis): Redis => { lua: UPDATE_CUSTOMER_PRODUCT_SCRIPT, }); + redisInstance.defineCommand("claimLockReceipt", { + numberOfKeys: 1, + lua: CLAIM_LOCK_RECEIPT_SCRIPT, + }); + redisInstance.on("error", (error) => { console.error(`[Redis] Connection error:`, error.message); }); @@ -380,7 +380,6 @@ declare module "ioredis" { cacheKey: string, paramsJson: string, ): Promise; - unwindAndDeduct(paramsJson: string): Promise; deleteFullCustomerCache( testGuardKey: string, guardKey: string, @@ -427,6 +426,7 @@ declare module "ioredis" { cacheKey: string, paramsJson: string, ): Promise; + claimLockReceipt(lockReceiptKey: string): Promise; } } diff --git a/server/src/internal/api/check/handleCheck.ts b/server/src/internal/api/check/handleCheck.ts index 48fa18325..da20645ae 100644 --- a/server/src/internal/api/check/handleCheck.ts +++ b/server/src/internal/api/check/handleCheck.ts @@ -6,6 +6,7 @@ import { CheckParamsSchema, CheckQuerySchema, type CheckResponseV3, + type ParsedCheckParams, } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { parseCheckParamsForLock } from "@/internal/balances/utils/lock/parseCheckParamsForLock.js"; @@ -24,11 +25,11 @@ export const handleCheck = createRoute({ resource: AffectedResource.Check, body: CheckParamsSchema, handler: async (c) => { - let body = c.req.valid("json"); + const rawBody = c.req.valid("json"); const ctx = c.get("ctx"); - body = parseCheckParamsForLock({ - params: body, + const body: ParsedCheckParams = parseCheckParamsForLock({ + params: rawBody, }); const { diff --git a/server/src/internal/api/check/runCheckWithTrack.ts b/server/src/internal/api/check/runCheckWithTrack.ts index 78aef237a..c9065041e 100644 --- a/server/src/internal/api/check/runCheckWithTrack.ts +++ b/server/src/internal/api/check/runCheckWithTrack.ts @@ -1,11 +1,14 @@ import { ApiVersion, - type CheckParams, type CheckResponseV3, CheckResponseV3Schema, + ErrCode, FeatureType, + type FullCustomer, + featureUtils, InsufficientBalanceError, InternalError, + type ParsedCheckParams, RecaseError, type TrackParams, } from "@autumn/shared"; @@ -13,6 +16,7 @@ import type { AutumnContext } from "@server/honoUtils/HonoEnv.js"; import { runTrackV2 } from "@server/internal/balances/track/runTrackV2"; import { getTrackFeatureDeductions } from "@server/internal/balances/track/utils/getFeatureDeductions.js"; import { featureToCreditSystem } from "@server/internal/features/creditSystemUtils.js"; +import { workflows } from "@/queue/workflows.js"; import type { CheckData } from "./checkTypes/CheckData.js"; export const runCheckWithTrack = async ({ @@ -22,7 +26,7 @@ export const runCheckWithTrack = async ({ checkData, }: { ctx: AutumnContext; - body: CheckParams; + body: ParsedCheckParams; requiredBalance: number; checkData: CheckData; }): Promise => { @@ -39,6 +43,14 @@ export const runCheckWithTrack = async ({ }); } + if (body.lock && featureUtils.isAllocated(checkData.featureToUse)) { + throw new RecaseError({ + message: "Lock is not supported for allocated features", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + const featureDeductions = getTrackFeatureDeductions({ ctx, featureId: body.feature_id, @@ -58,6 +70,7 @@ export const runCheckWithTrack = async ({ }; let allowed = true; + let fullCustomer: FullCustomer | undefined; try { // Use V2_1 to get ApiBalanceV1 format internally @@ -89,13 +102,29 @@ export const runCheckWithTrack = async ({ }); } + // Schedule lock expiration if it exists + if (body.lock?.expires_at && allowed) { + await workflows.triggerExpireLockReceipt( + { + orgId: ctx.org.id, + env: ctx.env, + customerId: body.customer_id, + lockKey: body.lock.key, + hashedKey: body.lock.hashed_key, + }, + { + scheduleAt: new Date(body.lock.expires_at), + }, + ); + } + const checkResponse = CheckResponseV3Schema.parse({ allowed, customer_id: checkData.customerId || "", entity_id: checkData.entityId, required_balance: requiredBalance, balance: checkData.apiBalance ?? null, - lock_key: body.lock?.key, + // lock_key: body.lock?.key, }); return checkResponse; diff --git a/server/src/internal/balances/finalizeLock/buildFinalizeLockContext.ts b/server/src/internal/balances/finalizeLock/buildFinalizeLockContext.ts new file mode 100644 index 000000000..4357869ab --- /dev/null +++ b/server/src/internal/balances/finalizeLock/buildFinalizeLockContext.ts @@ -0,0 +1,80 @@ +import type { Feature, FullCustomer } from "@autumn/shared"; +import { type FinalizeLockParamsV0, findFeatureById } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { + fetchLockReceipt, + type LockReceipt, +} from "@/internal/balances/utils/lock/fetchLockReceipt.js"; +import { + calculateLockValue, + calculateUnwindValue, +} from "@/internal/balances/utils/lock/unwindLockUtils.js"; +import type { FeatureDeduction } from "@/internal/balances/utils/types/featureDeduction.js"; +import { getOrSetCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js"; + +export type FinalizeLockContext = { + receipt: LockReceipt; + lockReceiptKey: string; + fullCustomer: FullCustomer; + feature: Feature; + lockValue: number; + finalValue: number; + unwindValue: number; + additionalValue: number; + deduction: FeatureDeduction; + deductionOptions: { triggerAutoTopUp: boolean }; +}; + +export const buildFinalizeLockContext = async ({ + ctx, + params, +}: { + ctx: AutumnContext; + params: FinalizeLockParamsV0; +}): Promise => { + const { receipt, lockReceiptKey } = await fetchLockReceipt({ + ctx, + lockKey: params.lock_key, + }); + + const fullCustomer = await getOrSetCachedFullCustomer({ + ctx, + customerId: receipt.customer_id, + entityId: receipt.entity_id ?? undefined, + source: "runFinalizeLock", + }); + + const lockValue = calculateLockValue({ items: receipt.items }); + const finalValue = + params.action === "release" ? 0 : (params.override_value ?? lockValue); + + const { unwindValue, additionalValue } = calculateUnwindValue({ + receipt, + finalValue, + }); + + const feature = findFeatureById({ + features: ctx.features, + featureId: receipt.feature_id, + errorOnNotFound: true, + }); + + return { + receipt, + lockReceiptKey, + fullCustomer, + feature, + lockValue, + finalValue, + unwindValue, + additionalValue, + deduction: { + feature, + deduction: additionalValue, + lockReceipt: receipt, + unwindValue, + lockReceiptKey, + }, + deductionOptions: { triggerAutoTopUp: true }, + }; +}; diff --git a/server/src/internal/balances/finalizeLock/executeRedisUnwindAndDeduct.ts b/server/src/internal/balances/finalizeLock/executeRedisUnwindAndDeduct.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/src/internal/balances/finalizeLock/expireLock.ts b/server/src/internal/balances/finalizeLock/expireLock.ts new file mode 100644 index 000000000..663514aef --- /dev/null +++ b/server/src/internal/balances/finalizeLock/expireLock.ts @@ -0,0 +1,32 @@ +import { RecaseError } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { ExpireLockReceiptPayload } from "@/queue/workflows.js"; +import { runFinalizeLock } from "./runFinalizeLock.js"; + +export const expireLock = async ({ + ctx, + payload, +}: { + ctx: AutumnContext; + payload: ExpireLockReceiptPayload; +}) => { + try { + ctx.skipCache = false; + await runFinalizeLock({ + ctx, + params: { + lock_key: payload.lockKey, + action: "release", + override_value: 0, + }, + }); + } catch (error) { + if ( + error instanceof RecaseError && + error.message.includes("Lock not found") + ) { + return; + } + throw error; + } +}; diff --git a/server/src/internal/balances/finalizeLock/finalizeLock.ts b/server/src/internal/balances/finalizeLock/finalizeLock.ts deleted file mode 100644 index e9ab4acbe..000000000 --- a/server/src/internal/balances/finalizeLock/finalizeLock.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { - type FinalizeLockParamsV0, - findFeatureById, - tryCatch, -} from "@autumn/shared"; -import { currentRegion } from "@/external/redis/initRedis.js"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { executePostgresDeduction } from "@/internal/balances/utils/deduction/executePostgresDeduction.js"; -import { executeRedisDeduction } from "@/internal/balances/utils/deduction/executeRedisDeduction.js"; -import { fetchLockReceipt } from "@/internal/balances/utils/lock/fetchLockReceipt.js"; -import { calculateUnwindValue } from "@/internal/balances/utils/lock/unwindLockUtils.js"; -import { deductionUpdatesToModifiedIds } from "@/internal/balances/utils/sync/deductionUpdatesToModifiedIds.js"; -import { globalSyncBatchingManagerV2 } from "@/internal/balances/utils/sync/SyncBatchingManagerV2.js"; -import type { DeductionUpdate } from "@/internal/balances/utils/types/deductionUpdate.js"; -import type { FeatureDeduction } from "@/internal/balances/utils/types/featureDeduction.js"; -import { RedisDeductionError } from "@/internal/balances/utils/types/redisDeductionError.js"; -import type { RolloverUpdate } from "@/internal/balances/utils/types/rolloverUpdate.js"; -import { getOrSetCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js"; - -const queueSyncItem = ({ - ctx, - customerId, - updates, - rolloverUpdates, -}: { - ctx: AutumnContext; - customerId: string; - updates: Record; - rolloverUpdates: Record; -}) => { - const modifiedCusEntIds = deductionUpdatesToModifiedIds({ updates }); - const rolloverIds = Object.keys(rolloverUpdates); - - if (modifiedCusEntIds.length === 0 && rolloverIds.length === 0) return; - - ctx.logger.info(`[QUEUE SYNC] (${customerId})`); - globalSyncBatchingManagerV2.addSyncItem({ - customerId, - orgId: ctx.org.id, - env: ctx.env, - cusEntIds: modifiedCusEntIds, - rolloverIds, - region: currentRegion, - }); -}; - -export const finalizeLock = async ({ - ctx, - params, -}: { - ctx: AutumnContext; - params: FinalizeLockParamsV0; -}) => { - const { receipt, lockReceiptKey } = await fetchLockReceipt({ - ctx, - lockKey: params.lock_key, - }); - - const fullCustomer = await getOrSetCachedFullCustomer({ - ctx, - customerId: receipt.customer_id!, - entityId: receipt.entity_id ?? undefined, - source: "finalizeLock", - }); - - const finalValue = - params.finalize_action === "release" ? 0 : params.overwrite_value; - - const { unwindValue, additionalValue } = calculateUnwindValue({ - receipt, - finalValue, - }); - - const feature = findFeatureById({ - features: ctx.features, - featureId: receipt.feature_id, - errorOnNotFound: true, - }); - - const deduction: FeatureDeduction = { - feature, - deduction: additionalValue, - lockReceipt: receipt, - - // For unwinding when finalizing a lock - unwindValue, - lockReceiptKey, - }; - - const deductionOptions = { - triggerAutoTopUp: true, - }; - - const { data: redisResult, error } = await tryCatch( - executeRedisDeduction({ - ctx, - fullCustomer, - entityId: receipt.entity_id ?? undefined, - deductions: [deduction], - deductionOptions, - }), - ); - - if (error) { - if (error instanceof RedisDeductionError && error.shouldFallback()) { - ctx.logger.warn( - `Falling back to Postgres for finalize lock: ${error.code}`, - ); - - await executePostgresDeduction({ - ctx, - fullCustomer, - customerId: receipt.customer_id, - entityId: receipt.entity_id ?? undefined, - deductions: [deduction], - options: deductionOptions, - }); - - return { - success: true, - }; - } - - throw error; - } - - const { updates, rolloverUpdates } = redisResult; - - queueSyncItem({ - ctx, - customerId: receipt.customer_id, - updates, - rolloverUpdates, - }); - - return { - success: true, - }; -}; diff --git a/server/src/internal/balances/finalizeLock/insertFinalizeLockEvent.ts b/server/src/internal/balances/finalizeLock/insertFinalizeLockEvent.ts new file mode 100644 index 000000000..e2c92383d --- /dev/null +++ b/server/src/internal/balances/finalizeLock/insertFinalizeLockEvent.ts @@ -0,0 +1,29 @@ +import { Decimal } from "decimal.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { globalEventBatchingManager } from "@/internal/balances/events/EventBatchingManager.js"; +import { initEvent } from "@/internal/balances/events/initEvent.js"; +import type { FinalizeLockContext } from "./buildFinalizeLockContext.js"; + +/** Constructs and queues a finalize lock event. Event value = finalValue - lockValue. */ +export const insertFinalizeLockEvent = ({ + ctx, + finalizeLockContext, +}: { + ctx: AutumnContext; + finalizeLockContext: FinalizeLockContext; +}) => { + const { receipt, fullCustomer, finalValue, lockValue } = finalizeLockContext; + const event = initEvent({ + ctx, + eventInfo: { + event_name: receipt.feature_id, + value: new Decimal(finalValue).sub(lockValue).toNumber(), + }, + internalCustomerId: fullCustomer.internal_id, + internalEntityId: fullCustomer.entity?.internal_id, + customerId: receipt.customer_id, + entityId: receipt.entity_id ?? undefined, + }); + + globalEventBatchingManager.addEvent(event); +}; diff --git a/server/src/internal/balances/finalizeLock/runFinalizeLock.ts b/server/src/internal/balances/finalizeLock/runFinalizeLock.ts new file mode 100644 index 000000000..ccbf93478 --- /dev/null +++ b/server/src/internal/balances/finalizeLock/runFinalizeLock.ts @@ -0,0 +1,41 @@ +import type { FinalizeLockParamsV0 } from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { cancelLockExpiry } from "@/internal/balances/utils/lock/cancelLockExpiry.js"; +import { claimLockReceipt } from "@/internal/balances/utils/lock/claimLockReceipt.js"; +import { deleteLockReceipt } from "@/internal/balances/utils/lock/deleteLockReceipt.js"; +import { buildFinalizeLockContext } from "./buildFinalizeLockContext.js"; +import { runRedisFinalizeLock } from "./runRedisFinalizeLock.js"; + +export const runFinalizeLock = async ({ + ctx, + params, +}: { + ctx: AutumnContext; + params: FinalizeLockParamsV0; +}) => { + const finalizeLockContext = await buildFinalizeLockContext({ ctx, params }); + const { lockReceiptKey, receipt, finalValue, lockValue } = + finalizeLockContext; + + // Claim on the receipt's origin region to prevent cross-region double-claim + const { redisInstance } = await claimLockReceipt({ + lockReceiptKey, + receiptRegion: receipt.region, + }); + + // Cancel any pending EventBridge expiry schedule for this lock + await cancelLockExpiry({ hashedKey: Bun.hash(params.lock_key).toString() }); + + // No-op deduction: finalValue == lockValue means nothing changed, just delete the receipt + if (new Decimal(finalValue).equals(lockValue)) { + await deleteLockReceipt({ lockReceiptKey, redisInstance }); + return { success: true }; + } + + await runRedisFinalizeLock({ ctx, finalizeLockContext, redisInstance }); + + await deleteLockReceipt({ lockReceiptKey, redisInstance }); + + return { success: true }; +}; diff --git a/server/src/internal/balances/finalizeLock/runPostgresFinalizeLock.ts b/server/src/internal/balances/finalizeLock/runPostgresFinalizeLock.ts new file mode 100644 index 000000000..958badcca --- /dev/null +++ b/server/src/internal/balances/finalizeLock/runPostgresFinalizeLock.ts @@ -0,0 +1,32 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { executePostgresDeduction } from "@/internal/balances/utils/deduction/executePostgresDeduction.js"; +import type { FinalizeLockContext } from "./buildFinalizeLockContext.js"; +import { insertFinalizeLockEvent } from "./insertFinalizeLockEvent.js"; + +export const runPostgresFinalizeLock = async ({ + ctx, + finalizeLockContext, +}: { + ctx: AutumnContext; + finalizeLockContext: FinalizeLockContext; +}) => { + const { + receipt, + fullCustomer, + finalValue, + lockValue, + deduction, + deductionOptions, + } = finalizeLockContext; + + await executePostgresDeduction({ + ctx, + fullCustomer, + customerId: receipt.customer_id, + entityId: receipt.entity_id ?? undefined, + deductions: [deduction], + options: deductionOptions, + }); + + insertFinalizeLockEvent({ ctx, finalizeLockContext }); +}; diff --git a/server/src/internal/balances/finalizeLock/runRedisFinalizeLock.ts b/server/src/internal/balances/finalizeLock/runRedisFinalizeLock.ts new file mode 100644 index 000000000..a3189b7bc --- /dev/null +++ b/server/src/internal/balances/finalizeLock/runRedisFinalizeLock.ts @@ -0,0 +1,69 @@ +import type { Redis } from "ioredis"; +import { currentRegion } from "@/external/redis/initRedis.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { executeRedisDeduction } from "@/internal/balances/utils/deduction/executeRedisDeduction.js"; +import { deductionUpdatesToModifiedIds } from "@/internal/balances/utils/sync/deductionUpdatesToModifiedIds.js"; +import { globalSyncBatchingManagerV2 } from "@/internal/balances/utils/sync/SyncBatchingManagerV2.js"; +import { RedisDeductionError } from "@/internal/balances/utils/types/redisDeductionError.js"; +import type { FinalizeLockContext } from "./buildFinalizeLockContext.js"; +import { insertFinalizeLockEvent } from "./insertFinalizeLockEvent.js"; +import { runPostgresFinalizeLock } from "./runPostgresFinalizeLock.js"; + +export const runRedisFinalizeLock = async ({ + ctx, + finalizeLockContext, + redisInstance, +}: { + ctx: AutumnContext; + finalizeLockContext: FinalizeLockContext; + redisInstance?: Redis; +}) => { + const { + receipt, + fullCustomer, + + deduction, + deductionOptions, + } = finalizeLockContext; + + let redisResult: Awaited>; + + try { + redisResult = await executeRedisDeduction({ + ctx, + fullCustomer, + entityId: receipt.entity_id ?? undefined, + deductions: [deduction], + deductionOptions, + redisInstance, + }); + } catch (error) { + if (error instanceof RedisDeductionError && error.shouldFallback()) { + ctx.logger.warn( + `[FINALIZE LOCK] Falling back to Postgres: ${error.code}`, + ); + await runPostgresFinalizeLock({ ctx, finalizeLockContext }); + return; + } + throw error; + } + + const { updates, rolloverUpdates } = redisResult; + + const modifiedCusEntIds = deductionUpdatesToModifiedIds({ updates }); + const rolloverIds = Object.keys(rolloverUpdates); + + if (modifiedCusEntIds.length > 0 || rolloverIds.length > 0) { + ctx.logger.info(`[QUEUE SYNC] (${receipt.customer_id})`); + globalSyncBatchingManagerV2.addSyncItem({ + customerId: receipt.customer_id, + orgId: ctx.org.id, + env: ctx.env, + cusEntIds: modifiedCusEntIds, + rolloverIds, + region: currentRegion, + }); + } + + insertFinalizeLockEvent({ ctx, finalizeLockContext }); +}; diff --git a/server/src/internal/balances/handlers/handleFinalizeLock.ts b/server/src/internal/balances/handlers/handleFinalizeLock.ts index 36fca00a2..391fc7b06 100644 --- a/server/src/internal/balances/handlers/handleFinalizeLock.ts +++ b/server/src/internal/balances/handlers/handleFinalizeLock.ts @@ -1,6 +1,6 @@ import { FinalizeLockParamsV0Schema } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler"; -import { finalizeLock } from "../finalizeLock/finalizeLock"; +import { runFinalizeLock } from "../finalizeLock/runFinalizeLock.js"; export const handleFinalizeLock = createRoute({ body: FinalizeLockParamsV0Schema, @@ -9,7 +9,7 @@ export const handleFinalizeLock = createRoute({ const params = c.req.valid("json"); return c.json( - await finalizeLock({ + await runFinalizeLock({ ctx, params, }), diff --git a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts index 06bd307cc..924c26bd4 100644 --- a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts @@ -77,6 +77,12 @@ export const executePostgresDeduction = async ({ deductions, }); + if (resolvedOptions.paidAllocated && deductions.some((d) => d.lock)) { + throw new InternalError({ + message: "Locks are not supported for paid allocated features", + }); + } + const executeDeduction = async (): Promise<{ updates: Record; mutationLogs: MutationLogItem[]; diff --git a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts index f248c1562..6a082544e 100644 --- a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts @@ -2,6 +2,7 @@ import type { FullCusEntWithFullCusProduct, FullCustomer, } from "@autumn/shared"; +import type { Redis } from "ioredis"; import { redis } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { triggerAutoTopUp } from "@/internal/balances/autoTopUp/triggerAutoTopUp.js"; @@ -32,12 +33,14 @@ export const executeRedisDeduction = async ({ deductions, fullCustomer, deductionOptions = {}, + redisInstance, }: { ctx: AutumnContext; entityId?: string; deductions: FeatureDeduction[]; fullCustomer: FullCustomer; deductionOptions?: DeductionOptions; + redisInstance?: Redis; }): Promise<{ oldFullCus: FullCustomer; fullCus: FullCustomer | undefined; @@ -61,6 +64,13 @@ export const executeRedisDeduction = async ({ }); } + if (options.paidAllocated && deductions.some((d) => d.lock)) { + throw new RedisDeductionError({ + message: "Locks are not supported for paid allocated features", + code: RedisDeductionErrorCode.PaidAllocated, + }); + } + if (ctx.skipCache) { throw new RedisDeductionError({ message: `Skipping cache is not supported for Redis`, @@ -124,8 +134,14 @@ export const executeRedisDeduction = async ({ lock_receipt_key: lockReceiptKey ?? null, }; - const result = await tryRedisWrite(() => - redis.deductFromCustomerEntitlements(cacheKey, JSON.stringify(luaParams)), + const targetRedis = redisInstance ?? redis; + const result = await tryRedisWrite( + () => + targetRedis.deductFromCustomerEntitlements( + cacheKey, + JSON.stringify(luaParams), + ), + redisInstance, ); if (!result) { @@ -150,7 +166,10 @@ export const executeRedisDeduction = async ({ }); } - const { updates, rollover_updates, mutation_logs } = resultJson; + const { updates, rollover_updates } = resultJson; + const mutation_logs = Array.isArray(resultJson.mutation_logs) + ? resultJson.mutation_logs + : []; logDeductionUpdates({ ctx, fullCustomer, diff --git a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts index 10f93ab9d..1bce2b452 100644 --- a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts +++ b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts @@ -120,6 +120,9 @@ export const prepareFeatureDeduction = ({ return 0; }); + const ONE_DAY_S = 24 * 60 * 60; + const ONE_HOUR_S = 60 * 60; + const preparedLock = lock ? { ...lock, @@ -130,6 +133,10 @@ export const prepareFeatureDeduction = ({ lockKey: lock.hashed_key ?? Bun.hash(lock.key!).toString(), }), created_at: Date.now(), + // TTL: expires_at + 1 hour (in seconds), or now + 1 day + ttl_at: lock.expires_at + ? Math.ceil(lock.expires_at / 1000) + ONE_HOUR_S + : Math.ceil(Date.now() / 1000) + ONE_DAY_S, } : undefined; diff --git a/server/src/internal/balances/utils/lock/cancelLockExpiry.ts b/server/src/internal/balances/utils/lock/cancelLockExpiry.ts new file mode 100644 index 000000000..c33719c4a --- /dev/null +++ b/server/src/internal/balances/utils/lock/cancelLockExpiry.ts @@ -0,0 +1,10 @@ +import { deleteSchedule } from "@/external/aws/eventbridge/eventBridgeUtils.js"; + +/** Cancels the EventBridge expiry schedule for a lock receipt. Safe to call even if no schedule exists. */ +export const cancelLockExpiry = async ({ + hashedKey, +}: { + hashedKey: string; +}) => { + await deleteSchedule({ scheduleName: `lock-${hashedKey}` }); +}; diff --git a/server/src/internal/balances/utils/lock/claimLockReceipt.ts b/server/src/internal/balances/utils/lock/claimLockReceipt.ts new file mode 100644 index 000000000..6840f4a31 --- /dev/null +++ b/server/src/internal/balances/utils/lock/claimLockReceipt.ts @@ -0,0 +1,56 @@ +import { ErrCode, InternalError, RecaseError } from "@autumn/shared"; +import type { Redis } from "ioredis"; +import { + currentRegion, + getRegionalRedis, + redis, +} from "@/external/redis/initRedis.js"; +import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; + +/** + * Atomically claims a lock receipt: pending → processing. + * + * Routes the claim to the Redis instance the receipt was originally written to + * (identified by receipt.region) so that Active-Active replication cannot allow + * two concurrent claims on separate regional instances. + * + * Returns the Redis instance that was used — callers must use it for all + * subsequent operations (unwind deduction, delete) to stay on the same instance. + * + * Throws RecaseError for terminal/already-processing statuses. + * Throws InternalError when Redis is unavailable. + */ +export const claimLockReceipt = async ({ + lockReceiptKey, + receiptRegion, +}: { + lockReceiptKey: string; + receiptRegion?: string | null; +}): Promise<{ redisInstance: Redis }> => { + const redisInstance = + receiptRegion && receiptRegion !== currentRegion + ? getRegionalRedis(receiptRegion) + : redis; + + const result = await tryRedisWrite( + () => redisInstance.claimLockReceipt(lockReceiptKey), + redisInstance, + ); + + if (result === null) { + throw new InternalError({ + message: "Redis not ready for claimLockReceipt", + }); + } + + if (result === "OK") { + return { redisInstance }; + } + + throw new RecaseError({ + message: `Lock receipt not claimable: ${result}`, + code: ErrCode.InvalidRequest, + statusCode: 409, + data: { blockingStatus: result }, + }); +}; diff --git a/server/src/internal/balances/utils/lock/deleteLockReceipt.ts b/server/src/internal/balances/utils/lock/deleteLockReceipt.ts new file mode 100644 index 000000000..64fb0d314 --- /dev/null +++ b/server/src/internal/balances/utils/lock/deleteLockReceipt.ts @@ -0,0 +1,18 @@ +import type { Redis } from "ioredis"; +import { redis } from "@/external/redis/initRedis.js"; +import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; + +/** Removes a lock receipt from Redis after successful finalize or expiry. */ +export const deleteLockReceipt = async ({ + lockReceiptKey, + redisInstance, +}: { + lockReceiptKey: string; + redisInstance?: Redis; +}): Promise => { + const targetRedis = redisInstance ?? redis; + await tryRedisWrite( + () => targetRedis.del(lockReceiptKey) as Promise, + redisInstance, + ); +}; diff --git a/server/src/internal/balances/utils/lock/fetchLockReceipt.ts b/server/src/internal/balances/utils/lock/fetchLockReceipt.ts index a86bb877f..8949d7ec5 100644 --- a/server/src/internal/balances/utils/lock/fetchLockReceipt.ts +++ b/server/src/internal/balances/utils/lock/fetchLockReceipt.ts @@ -9,6 +9,7 @@ export type LockReceipt = { customer_id: string; feature_id: string; entity_id?: string | null; + region?: string | null; items: MutationLogItem[]; }; diff --git a/server/src/internal/balances/utils/lock/parseCheckParamsForLock.ts b/server/src/internal/balances/utils/lock/parseCheckParamsForLock.ts index d76465d11..eda8950b1 100644 --- a/server/src/internal/balances/utils/lock/parseCheckParamsForLock.ts +++ b/server/src/internal/balances/utils/lock/parseCheckParamsForLock.ts @@ -1,11 +1,19 @@ import { generateKsuid } from "@autumn/ksuid"; -import { type CheckParams, type LockParams, RecaseError } from "@autumn/shared"; +import { + type CheckParams, + ErrCode, + type ParsedCheckParams, + type ParsedLockParams, + RecaseError, +} from "@autumn/shared"; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; export const parseCheckParamsForLock = ({ params, }: { params: CheckParams; -}) => { +}): ParsedCheckParams => { const { lock } = params; if (!lock?.enabled) { return { @@ -17,13 +25,26 @@ export const parseCheckParamsForLock = ({ if (lock.key && lock.key.length > 256) { throw new RecaseError({ message: "Lock key cannot exceed 256 characters", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + if ( + lock.expires_at !== undefined && + lock.expires_at > Date.now() + ONE_DAY_MS + ) { + throw new RecaseError({ + message: "Lock expires_at cannot be more than 1 day from now", + code: ErrCode.InvalidRequest, + statusCode: 400, }); } const lockKey = lock.key ?? generateKsuid({ prefix: "lck" }); const hashedKey = Bun.hash(lockKey).toString(); - const finalLock: LockParams = { + const finalLock: ParsedLockParams = { enabled: true, key: lockKey, hashed_key: hashedKey, diff --git a/server/src/internal/balances/utils/lock/saveLockReceipt.ts b/server/src/internal/balances/utils/lock/saveLockReceipt.ts index a54ff92ff..1ba985f33 100644 --- a/server/src/internal/balances/utils/lock/saveLockReceipt.ts +++ b/server/src/internal/balances/utils/lock/saveLockReceipt.ts @@ -1,5 +1,5 @@ import { InternalError } from "@autumn/shared"; -import { redis } from "@/external/redis/initRedis.js"; +import { currentRegion, redis } from "@/external/redis/initRedis.js"; import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLogItem.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; @@ -13,9 +13,10 @@ export const saveLockReceipt = async ({ lock: { key?: string; hashed_key?: string; - expires_at?: string; + expires_at?: number; redis_receipt_key: string; created_at: number; + ttl_at: number; }; customerId: string; featureId: string; @@ -32,6 +33,7 @@ export const saveLockReceipt = async ({ lock_key: lock.key ?? null, hashed_key: lock.hashed_key ?? null, status: "pending", + region: currentRegion, customer_id: customerId, feature_id: featureId, entity_id: entityId ?? null, @@ -42,7 +44,10 @@ export const saveLockReceipt = async ({ ) as Promise<"OK" | null>, ); - if (result === "OK") return; + if (result === "OK") { + await redis.expireat(lock.redis_receipt_key, lock.ttl_at); + return; + } throw new InternalError({ message: `Failed to save lock receipt for key: ${lock.key ?? lock.hashed_key}`, diff --git a/server/src/internal/balances/utils/sql/performDeduction.sql b/server/src/internal/balances/utils/sql/performDeduction.sql index a370ed57f..3888a1dd1 100644 --- a/server/src/internal/balances/utils/sql/performDeduction.sql +++ b/server/src/internal/balances/utils/sql/performDeduction.sql @@ -69,7 +69,7 @@ DECLARE step_mutation_logs jsonb := '[]'::jsonb; unwind_updates_json jsonb := '{}'::jsonb; unwind_modified_rollover_ids text[] := ARRAY[]::text[]; - unwind_remaining_value numeric := 0; + signed_remaining_unwind_value numeric := 0; -- Tracking updates_json jsonb := '{}'::jsonb; rollover_updates_json jsonb := '[]'::jsonb; @@ -128,14 +128,21 @@ BEGIN -- ============================================================================ IF lock_receipt IS NOT NULL AND COALESCE(unwind_value, 0) > 0 THEN SELECT * - INTO unwind_remaining_value, unwind_updates_json, unwind_modified_rollover_ids, step_mutation_logs + INTO signed_remaining_unwind_value, unwind_updates_json, unwind_modified_rollover_ids, step_mutation_logs FROM unwind_from_lock_receipt(jsonb_build_object( 'lock_receipt', lock_receipt, - 'unwind_value', unwind_value + 'unwind_value', unwind_value, + 'cus_ent_ids', to_jsonb(cus_ent_ids) )); updates_json := updates_json || COALESCE(unwind_updates_json, '{}'::jsonb); mutation_logs_json := mutation_logs_json || COALESCE(step_mutation_logs, '[]'::jsonb); + + -- Fold any skipped unwind (missing entitlements/rollovers) into amount_to_deduct + -- so the forward pass compensates against current live entitlements. + IF signed_remaining_unwind_value <> 0 THEN + amount_to_deduct := COALESCE(amount_to_deduct, 0) + signed_remaining_unwind_value; + END IF; END IF; -- ============================================================================ diff --git a/server/src/internal/balances/utils/sql/unwindFromLockReceipt.sql b/server/src/internal/balances/utils/sql/unwindFromLockReceipt.sql index 2bcf005c7..861da5f73 100644 --- a/server/src/internal/balances/utils/sql/unwindFromLockReceipt.sql +++ b/server/src/internal/balances/utils/sql/unwindFromLockReceipt.sql @@ -2,7 +2,7 @@ DROP FUNCTION IF EXISTS unwind_from_lock_receipt(jsonb); CREATE FUNCTION unwind_from_lock_receipt(params jsonb) RETURNS TABLE ( - remaining_unwind_value numeric, + signed_remaining_unwind_value numeric, updates jsonb, modified_rollover_ids text[], mutation_logs jsonb @@ -13,6 +13,11 @@ DECLARE lock_receipt jsonb := params->'lock_receipt'; receipt_items jsonb := COALESCE(lock_receipt->'items', '[]'::jsonb); requested_unwind_value numeric := COALESCE((params->>'unwind_value')::numeric, 0); + -- Live entitlement IDs: only these may be unwound; anything else is skipped and compensated + live_cus_ent_ids text[] := CASE + WHEN params->'cus_ent_ids' IS NULL OR jsonb_typeof(params->'cus_ent_ids') != 'array' THEN NULL + ELSE ARRAY(SELECT jsonb_array_elements_text(params->'cus_ent_ids')) + END; item_index integer; item jsonb; @@ -37,7 +42,13 @@ DECLARE updated_adjustment numeric; updated_entities jsonb; + rows_affected integer; + remaining_value numeric := requested_unwind_value; + skipped_unwind numeric := 0; + lock_value_sum numeric := 0; + lock_sign integer := 1; + updates_json jsonb := '{}'::jsonb; mutation_logs_json jsonb := '[]'::jsonb; modified_rollover_ids_array text[] := ARRAY[]::text[]; @@ -51,6 +62,15 @@ BEGIN RAISE EXCEPTION 'LOCK_RECEIPT_ITEMS_MISSING'; END IF; + -- Compute lock_sign from the sum of value_deltas across all receipt items. + -- unwind_value is always a positive magnitude; lock_sign tells us the direction + -- of the original deduction so we can correctly sign any skipped compensation. + SELECT COALESCE(SUM((item_el->>'value_delta')::numeric), 0) + INTO lock_value_sum + FROM jsonb_array_elements(receipt_items) item_el; + + lock_sign := CASE WHEN lock_value_sum >= 0 THEN 1 ELSE -1 END; + FOR item_index IN REVERSE jsonb_array_length(receipt_items) - 1..0 LOOP EXIT WHEN remaining_value <= 0; @@ -98,6 +118,18 @@ BEGIN RAISE EXCEPTION 'LOCK_CUSTOMER_ENTITLEMENT_ID_MISSING'; END IF; + -- If a live entitlement set was provided, skip any receipt item whose ID is + -- not in it (e.g. the entitlement belonged to a product that was upgraded + -- mid-flight and the row still exists in the DB but is no longer active). + IF live_cus_ent_ids IS NOT NULL AND NOT (customer_entitlement_id = ANY(live_cus_ent_ids)) THEN + skipped_unwind := skipped_unwind + unwind_iteration_value; + remaining_value := remaining_value - unwind_iteration_value; + CONTINUE; + END IF; + + -- Reset before the UPDATE so we can detect 0-row matches + updated_balance := NULL; + IF entity_id IS NULL THEN UPDATE customer_entitlements ce SET @@ -132,6 +164,15 @@ BEGIN INTO updated_balance, updated_additional_balance, updated_adjustment, updated_entities; END IF; + -- Entitlement no longer exists (e.g. product upgraded mid-flight). + -- Skip this item and accumulate the skipped magnitude so the caller + -- can compensate against current live entitlements. + IF updated_balance IS NULL THEN + skipped_unwind := skipped_unwind + unwind_iteration_value; + remaining_value := remaining_value - unwind_iteration_value; + CONTINUE; + END IF; + updates_json := jsonb_set( updates_json, ARRAY[customer_entitlement_id], @@ -172,6 +213,15 @@ BEGIN WHERE r.id = rollover_id; END IF; + GET DIAGNOSTICS rows_affected = ROW_COUNT; + + -- Rollover no longer exists (e.g. expired mid-flight). Skip and accumulate. + IF rows_affected = 0 THEN + skipped_unwind := skipped_unwind + unwind_iteration_value; + remaining_value := remaining_value - unwind_iteration_value; + CONTINUE; + END IF; + modified_rollover_ids_array := array_append(modified_rollover_ids_array, rollover_id); ELSE RAISE EXCEPTION 'INVALID_LOCK_ITEM_TARGET_TYPE|targetType:%', item_target_type; @@ -194,13 +244,14 @@ BEGIN remaining_value := remaining_value - unwind_iteration_value; END LOOP; - IF remaining_value > 0 THEN - RAISE EXCEPTION 'LOCK_UNWIND_INCOMPLETE|remaining:%', remaining_value; - END IF; - + -- signed_remaining_unwind_value: the signed compensation for any items that were + -- skipped because the target entitlement/rollover no longer exists. + -- A positive lock (deduction) that couldn't be restored → negative value (refund against current entitlements). + -- A negative lock (credit) that couldn't be taken back → positive value (deduction against current entitlements). + -- Callers add this directly to amount_to_deduct. RETURN QUERY SELECT - remaining_value, + (-lock_sign * skipped_unwind)::numeric, updates_json, modified_rollover_ids_array, mutation_logs_json; diff --git a/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts b/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts index 607d9d079..09f85db3a 100644 --- a/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts +++ b/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts @@ -189,8 +189,6 @@ class SyncBatchingManagerV2 { context: CustomerBatchContext; }): Promise { try { - const dedupHash = this.buildDeduplicationHash({ context }); - await addTaskToQueue({ jobName: JobName.SyncBalanceBatchV3, payload: { @@ -203,7 +201,7 @@ class SyncBatchingManagerV2 { rolloverIds: Array.from(context.rolloverIds), }, messageGroupId: context.customerId, - messageDeduplicationId: dedupHash, + generateDeduplicationId: false, }); logger.info( @@ -215,16 +213,6 @@ class SyncBatchingManagerV2 { ); } } - - private buildDeduplicationHash({ - context, - }: { - context: CustomerBatchContext; - }): string { - const dedupKey = `${context.orgId}:${context.env}:${context.customerId}`; - const dedupTimestamp = Math.floor(Date.now() / 10); - return Bun.hash(`${dedupKey}:${dedupTimestamp}`).toString(36); - } } export const globalSyncBatchingManagerV2 = new SyncBatchingManagerV2(); diff --git a/server/src/internal/balances/utils/sync/deductionUpdatesToModifiedIds.ts b/server/src/internal/balances/utils/sync/deductionUpdatesToModifiedIds.ts index 2d61291f5..9985ab067 100644 --- a/server/src/internal/balances/utils/sync/deductionUpdatesToModifiedIds.ts +++ b/server/src/internal/balances/utils/sync/deductionUpdatesToModifiedIds.ts @@ -10,5 +10,5 @@ export const deductionUpdatesToModifiedIds = ({ }: { updates: Record; }): string[] => { - return Object.keys(updates).filter((id) => updates[id].deducted !== 0); + return Object.keys(updates); }; diff --git a/server/src/internal/balances/utils/types/deductionTypes.ts b/server/src/internal/balances/utils/types/deductionTypes.ts index a4acd418e..1e67cc7e3 100644 --- a/server/src/internal/balances/utils/types/deductionTypes.ts +++ b/server/src/internal/balances/utils/types/deductionTypes.ts @@ -45,8 +45,9 @@ export type PreparedFeatureDeduction = { enabled: true; key?: string; hashed_key?: string; - expires_at?: string; + expires_at?: number; redis_receipt_key: string; created_at: number; + ttl_at: number; }; }; diff --git a/server/src/queue/JobName.ts b/server/src/queue/JobName.ts index 7ce161fac..877d86672 100644 --- a/server/src/queue/JobName.ts +++ b/server/src/queue/JobName.ts @@ -33,4 +33,7 @@ export enum JobName { // Hatchet workflows VerifyCacheConsistency = "verify-cache-consistency", + + // EventBridge scheduled jobs + ExpireLockReceipt = "expire-lock-receipt", } diff --git a/server/src/queue/initSqs.ts b/server/src/queue/initSqs.ts index b7777c87a..2af75683a 100644 --- a/server/src/queue/initSqs.ts +++ b/server/src/queue/initSqs.ts @@ -6,7 +6,7 @@ const DEFAULT_AWS_REGION = "us-west-2"; * Extracts the AWS region from a given SQS queue URL. * Returns undefined if the URL is empty or invalid. */ -function extractRegionFromQueueUrl({ +export function extractRegionFromQueueUrl({ queueUrl, }: { queueUrl: string | undefined; @@ -19,6 +19,8 @@ function extractRegionFromQueueUrl({ return match ? match[1] : undefined; } +// ============ FIFO Queue (primary) ============ + const getSqsClientConfig = () => ({ region: extractRegionFromQueueUrl({ @@ -45,5 +47,4 @@ export const recreateSqsClient = (): SQSClient => { /** Get the current SQS client (use this instead of direct sqs export for refreshable access) */ export const getSqsClient = (): SQSClient => sqsClient; -// SQS Queue URL - you'll need to create this queue in AWS console or via terraform export const QUEUE_URL = process.env.SQS_QUEUE_URL || ""; diff --git a/server/src/queue/initWorkers.ts b/server/src/queue/initWorkers.ts index cb565862c..6b4392c9c 100644 --- a/server/src/queue/initWorkers.ts +++ b/server/src/queue/initWorkers.ts @@ -18,16 +18,9 @@ import { getSqsClient, QUEUE_URL, recreateSqsClient } from "./initSqs.js"; import { JobName } from "./JobName.js"; import { processMessage, type SqsJob } from "./processMessage.js"; -// ============ State ============ +// ============ Shared State ============ let isRunning = true; let abortController: AbortController; -const isFifoQueue = QUEUE_URL.endsWith(".fifo"); - -// Stats tracking -let messagesProcessed = 0; -let totalMessagesProcessed = 0; -let lastStatsTime = Date.now(); -let activeMigrationJobs = 0; // Process recycling — exit after processing this many messages to prevent memory leaks const MAX_MESSAGES_BEFORE_RECYCLE = 50_000; @@ -39,13 +32,10 @@ const IDLE_SELF_KILL_THRESHOLD = 5; // ~5 min of 0 messages (5 * 60s) const MESSAGE_TIMEOUT_MS = 25_000; // Stale connection detection -let consecutiveEmptyPolls = 0; -let lastHeartbeatTime = Date.now(); const EMPTY_POLL_THRESHOLD = 9; // ~3 min of empty polls (9 * 20s wait) const HEARTBEAT_INTERVAL_MS = ms.minutes(5); // Zero-message alert tracking -let consecutiveZeroMessageIntervals = 0; const ZERO_MESSAGE_ALERT_THRESHOLD = 20; // ~20 min of 0 messages // ============ Helper Functions ============ @@ -61,199 +51,222 @@ const withTimeout = (promise: Promise, timeoutMs: number): Promise => ), ]); -const logPrefix = () => `[SQS Worker ${process.pid}]`; +const logPrefix = ({ queueUrl }: { queueUrl: string }) => + `[SQS Worker ${process.pid}][${queueUrl.split("/").pop()}]`; -const alertZeroMessages = () => { - const minutes = consecutiveZeroMessageIntervals; - logger.warn(`${logPrefix()} No messages processed for ${minutes} minutes`, { - type: "worker", - queueUrl: QUEUE_URL, - consecutiveIntervals: minutes, - }); - Sentry.captureMessage( - `SQS Worker ${process.pid}: No messages processed for ${minutes} minutes`, - "warning", - ); -}; +// ============ Polling Loop (per-queue, per-loop state) ============ -const logStatsAndCheckZeroMessages = () => { - const elapsedSeconds = ((Date.now() - lastStatsTime) / 1000).toFixed(0); - const mem = process.memoryUsage(); - console.log( - `${logPrefix()} Processed ${messagesProcessed} messages in ${elapsedSeconds}s | rss=${(mem.rss / 1024 / 1024).toFixed(0)}MB heap=${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB total=${totalMessagesProcessed}`, - ); +const startPollingLoop = async ({ + db, + queueUrl, + isFifo, + getSqsClientFn, + recreateSqsClientFn, +}: { + db: DrizzleCli; + queueUrl: string; + isFifo: boolean; + getSqsClientFn: () => SQSClient; + recreateSqsClientFn: () => SQSClient; +}) => { + // Per-loop state + let messagesProcessed = 0; + let totalMessagesProcessed = 0; + let lastStatsTime = Date.now(); + let activeMigrationJobs = 0; + let consecutiveEmptyPolls = 0; + let lastHeartbeatTime = Date.now(); + let consecutiveZeroMessageIntervals = 0; - if (messagesProcessed === 0) { - consecutiveZeroMessageIntervals++; + const prefix = logPrefix({ queueUrl }); - if ( - consecutiveZeroMessageIntervals >= IDLE_SELF_KILL_THRESHOLD && - totalMessagesProcessed > 0 && - activeMigrationJobs === 0 - ) { - console.log( - `${logPrefix()} Idle self-kill: 0 messages for ${consecutiveZeroMessageIntervals} intervals after processing ${totalMessagesProcessed} total. Exiting for cluster respawn.`, - ); - process.exit(0); - } + const alertZeroMessages = () => { + const minutes = consecutiveZeroMessageIntervals; + logger.warn(`${prefix} No messages processed for ${minutes} minutes`, { + type: "worker", + queueUrl, + consecutiveIntervals: minutes, + }); + Sentry.captureMessage( + `SQS Worker ${process.pid} (${queueUrl}): No messages processed for ${minutes} minutes`, + "warning", + ); + }; - if (consecutiveZeroMessageIntervals >= ZERO_MESSAGE_ALERT_THRESHOLD) { - alertZeroMessages(); + const logStatsAndCheckZeroMessages = () => { + const elapsedSeconds = ((Date.now() - lastStatsTime) / 1000).toFixed(0); + const mem = process.memoryUsage(); + console.log( + `${prefix} Processed ${messagesProcessed} messages in ${elapsedSeconds}s | rss=${(mem.rss / 1024 / 1024).toFixed(0)}MB heap=${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB total=${totalMessagesProcessed}`, + ); + + if (messagesProcessed === 0) { + consecutiveZeroMessageIntervals++; + + if ( + consecutiveZeroMessageIntervals >= IDLE_SELF_KILL_THRESHOLD && + totalMessagesProcessed > 0 && + activeMigrationJobs === 0 + ) { + console.log( + `${prefix} Idle self-kill: 0 messages for ${consecutiveZeroMessageIntervals} intervals after processing ${totalMessagesProcessed} total. Exiting for cluster respawn.`, + ); + process.exit(0); + } + + if (consecutiveZeroMessageIntervals >= ZERO_MESSAGE_ALERT_THRESHOLD) { + alertZeroMessages(); + consecutiveZeroMessageIntervals = 0; + } + } else { consecutiveZeroMessageIntervals = 0; } - } else { - consecutiveZeroMessageIntervals = 0; - } - messagesProcessed = 0; - lastStatsTime = Date.now(); -}; + messagesProcessed = 0; + lastStatsTime = Date.now(); + }; -const createReceiveCommand = () => - new ReceiveMessageCommand({ - QueueUrl: QUEUE_URL, - MaxNumberOfMessages: 10, - WaitTimeSeconds: 20, - VisibilityTimeout: 30, - ...(isFifoQueue && { ReceiveRequestAttemptId: generateId("receive") }), - }); + const createReceiveCommand = () => + new ReceiveMessageCommand({ + QueueUrl: queueUrl, + MaxNumberOfMessages: 10, + WaitTimeSeconds: 20, + VisibilityTimeout: 30, + ...(isFifo && { ReceiveRequestAttemptId: generateId("receive") }), + }); -const deleteMigrationJobImmediately = async ({ - sqs, - message, - job, -}: { - sqs: SQSClient; - message: Message; - job: SqsJob; -}) => { - logger.info( - `Returning success immediately for migration job ${job.data.migrationJobId}`, - ); - await sqs.send( - new DeleteMessageCommand({ - QueueUrl: QUEUE_URL, - ReceiptHandle: message.ReceiptHandle, - }), - ); -}; - -const handleSingleMessage = async ({ - sqs, - message, - db, -}: { - sqs: SQSClient; - message: Message; - db: DrizzleCli; -}): Promise<{ id: string; receiptHandle: string } | null> => { - if (!isRunning || !message.Body) return null; - - const job: SqsJob = JSON.parse(message.Body); - - // Migration jobs: delete IMMEDIATELY before processing (long-running, avoid timeout redelivery) - if (job.name === JobName.Migration) { - await deleteMigrationJobImmediately({ sqs, message, job }); - } - - const isMigration = job.name === JobName.Migration; - if (isMigration) { - await processMessage({ message, db }); - } else { - await withTimeout(processMessage({ message, db }), MESSAGE_TIMEOUT_MS); - } - - messagesProcessed++; - totalMessagesProcessed++; - - // Return delete info (skip migration jobs - already deleted) - if (message.ReceiptHandle && job.name !== JobName.Migration) { - return { id: message.MessageId!, receiptHandle: message.ReceiptHandle }; - } - return null; -}; - -const batchDeleteMessages = async ({ - sqs, - toDelete, -}: { - sqs: SQSClient; - toDelete: { Id: string; ReceiptHandle: string }[]; -}) => { - if (toDelete.length === 0) return; - - try { + const deleteMigrationJobImmediately = async ({ + sqs, + message, + job, + }: { + sqs: SQSClient; + message: Message; + job: SqsJob; + }) => { + logger.info( + `Returning success immediately for migration job ${job.data.migrationJobId}`, + ); await sqs.send( - new DeleteMessageBatchCommand({ QueueUrl: QUEUE_URL, Entries: toDelete }), + new DeleteMessageCommand({ + QueueUrl: queueUrl, + ReceiptHandle: message.ReceiptHandle, + }), ); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - console.error(`${logPrefix()} Batch delete failed: ${message}`); - } -}; + }; -const handleEmptyPoll = (): SQSClient | null => { - consecutiveEmptyPolls++; + const handleSingleMessage = async ({ + sqs, + message, + db, + }: { + sqs: SQSClient; + message: Message; + db: DrizzleCli; + }): Promise<{ id: string; receiptHandle: string } | null> => { + if (!isRunning || !message.Body) return null; - // Periodic heartbeat - const now = Date.now(); - if (now - lastHeartbeatTime > HEARTBEAT_INTERVAL_MS) { - console.log( - `${logPrefix()} Heartbeat - polling active, ${consecutiveEmptyPolls} consecutive empty polls`, - ); - lastHeartbeatTime = now; - } + const job: SqsJob = JSON.parse(message.Body); - // Recreate client if too many empty polls - if (consecutiveEmptyPolls >= EMPTY_POLL_THRESHOLD) { - console.warn( - `${logPrefix()} ${consecutiveEmptyPolls} consecutive empty polls - recreating SQS client`, - ); - consecutiveEmptyPolls = 0; - abortController = new AbortController(); - return recreateSqsClient(); - } + // Migration jobs: delete IMMEDIATELY before processing (long-running, avoid timeout redelivery) + if (job.name === JobName.Migration) { + await deleteMigrationJobImmediately({ sqs, message, job }); + } - return null; -}; + const isMigration = job.name === JobName.Migration; + if (isMigration) { + await processMessage({ message, db }); + } else { + await withTimeout(processMessage({ message, db }), MESSAGE_TIMEOUT_MS); + } -const handlePollingError = async ( - error: unknown, -): Promise => { - const err = error as { name?: string; message?: string }; + messagesProcessed++; + totalMessagesProcessed++; - if (err.name === "AbortError" || err.name === "RequestAbortedError") { - console.log(`${logPrefix()} Polling aborted (shutdown)`); + // Return delete info (skip migration jobs - already deleted) + if (message.ReceiptHandle && job.name !== JobName.Migration) { + return { id: message.MessageId!, receiptHandle: message.ReceiptHandle }; + } return null; - } + }; - if (!isRunning) return null; + const batchDeleteMessages = async ({ + sqs, + toDelete, + }: { + sqs: SQSClient; + toDelete: { Id: string; ReceiptHandle: string }[]; + }) => { + if (toDelete.length === 0) return; - console.error(`${logPrefix()} Polling error: ${err.message}`); + try { + await sqs.send( + new DeleteMessageBatchCommand({ + QueueUrl: queueUrl, + Entries: toDelete, + }), + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error(`${prefix} Batch delete failed: ${message}`); + } + }; + + const handleEmptyPoll = (): SQSClient | null => { + consecutiveEmptyPolls++; + + const now = Date.now(); + if (now - lastHeartbeatTime > HEARTBEAT_INTERVAL_MS) { + console.log( + `${prefix} Heartbeat - polling active, ${consecutiveEmptyPolls} consecutive empty polls`, + ); + lastHeartbeatTime = now; + } + + if (consecutiveEmptyPolls >= EMPTY_POLL_THRESHOLD) { + console.warn( + `${prefix} ${consecutiveEmptyPolls} consecutive empty polls - recreating SQS client`, + ); + consecutiveEmptyPolls = 0; + abortController = new AbortController(); + return recreateSqsClientFn(); + } + + return null; + }; + + const handlePollingError = async ( + error: unknown, + ): Promise => { + const err = error as { name?: string; message?: string }; + + if (err.name === "AbortError" || err.name === "RequestAbortedError") { + console.log(`${prefix} Polling aborted (shutdown)`); + return null; + } + + if (!isRunning) return null; + + console.error(`${prefix} Polling error: ${err.message}`); + + consecutiveEmptyPolls++; + if (consecutiveEmptyPolls >= EMPTY_POLL_THRESHOLD) { + console.warn(`${prefix} Repeated errors - recreating SQS client`); + consecutiveEmptyPolls = 0; + abortController = new AbortController(); + await new Promise((resolve) => setTimeout(resolve, 5000)); + return recreateSqsClientFn(); + } - // Recreate client on repeated errors - consecutiveEmptyPolls++; - if (consecutiveEmptyPolls >= EMPTY_POLL_THRESHOLD) { - console.warn(`${logPrefix()} Repeated errors - recreating SQS client`); - consecutiveEmptyPolls = 0; - abortController = new AbortController(); await new Promise((resolve) => setTimeout(resolve, 5000)); - return recreateSqsClient(); - } + return null; + }; - await new Promise((resolve) => setTimeout(resolve, 5000)); - return null; -}; - -// ============ Main Polling Loop ============ - -const startPollingLoop = async ({ db }: { db: DrizzleCli }) => { - console.log(`${logPrefix()} Started polling ${QUEUE_URL}`); - abortController = new AbortController(); + console.log(`${prefix} Started polling ${queueUrl}`); const statsInterval = setInterval(logStatsAndCheckZeroMessages, 60000); - let sqs = getSqsClient(); + let sqs = getSqsClientFn(); while (isRunning) { try { @@ -266,9 +279,6 @@ const startPollingLoop = async ({ db }: { db: DrizzleCli }) => { if (messages.length > 0) { consecutiveEmptyPolls = 0; - // Separate migration jobs — they're long-running and already deleted - // from the queue before processing, so fire-and-forget to avoid - // blocking the polling loop const regularMessages: Message[] = []; for (const message of messages) { if (!message.Body) continue; @@ -278,7 +288,7 @@ const startPollingLoop = async ({ db }: { db: DrizzleCli }) => { handleSingleMessage({ sqs, message, db }) .catch((error) => { console.error( - `${logPrefix()} Migration job failed:`, + `${prefix} Migration job failed:`, error instanceof Error ? error.message : error, ); Sentry.captureException(error); @@ -311,15 +321,12 @@ const startPollingLoop = async ({ db }: { db: DrizzleCli }) => { await batchDeleteMessages({ sqs, toDelete }); - // Clear Sentry scope to prevent memory accumulation from breadcrumbs/tags Sentry.getCurrentScope().clear(); - // Recycle process to prevent memory leaks from long-running workers - // Exit with code 0 so cluster primary respawns a fresh worker if (totalMessagesProcessed >= MAX_MESSAGES_BEFORE_RECYCLE) { const mem = process.memoryUsage(); console.log( - `${logPrefix()} Recycling after ${totalMessagesProcessed} messages (rss=${(mem.rss / 1024 / 1024).toFixed(0)}MB heap=${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB)`, + `${prefix} Recycling after ${totalMessagesProcessed} messages (rss=${(mem.rss / 1024 / 1024).toFixed(0)}MB heap=${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB)`, ); clearInterval(statsInterval); process.exit(0); @@ -336,12 +343,12 @@ const startPollingLoop = async ({ db }: { db: DrizzleCli }) => { } clearInterval(statsInterval); - console.log(`${logPrefix()} Stopped`); + console.log(`${prefix} Stopped`); }; /** - * Initialize single SQS poller for this process - * cluster.fork() in workers.ts handles multi-process parallelism + * Initialize SQS pollers for this process. + * cluster.fork() in workers.ts handles multi-process parallelism. */ export const initWorkers = async () => { const { db } = initDrizzle({ maxConnections: 10 }); @@ -362,7 +369,15 @@ export const initWorkers = async () => { process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); - await startPollingLoop({ db }); + abortController = new AbortController(); + + await startPollingLoop({ + db, + queueUrl: QUEUE_URL, + isFifo: QUEUE_URL.endsWith(".fifo"), + getSqsClientFn: getSqsClient, + recreateSqsClientFn: recreateSqsClient, + }); }; export const initHatchetWorker = async () => { @@ -378,8 +393,6 @@ export const initHatchetWorker = async () => { workflows: [verifyCacheConsistency!], }); - // Don't await - start() runs indefinitely and would block the rest of the code - // But catch errors to prevent unhandled promise rejections from crashing worker.start().catch((error) => { console.error("Hatchet worker error (non-fatal):", error.message); Sentry.captureException(error); diff --git a/server/src/queue/processMessage.ts b/server/src/queue/processMessage.ts index ac7c711c7..72b5dc76a 100644 --- a/server/src/queue/processMessage.ts +++ b/server/src/queue/processMessage.ts @@ -1,5 +1,6 @@ import type { Message } from "@aws-sdk/client-sqs"; import * as Sentry from "@sentry/bun"; +import chalk from "chalk"; import type { Logger } from "pino"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { logger } from "@/external/logtail/logtailUtils.js"; @@ -7,6 +8,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js"; import { autoTopup } from "@/internal/balances/autoTopUp/autoTopup.js"; import { runInsertEventBatch } from "@/internal/balances/events/runInsertEventBatch.js"; +import { expireLock } from "@/internal/balances/finalizeLock/expireLock.js"; import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; import { grantCheckoutReward } from "@/internal/billing/v2/workflows/grantCheckoutReward/grantCheckoutReward.js"; import { sendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.js"; @@ -59,7 +61,7 @@ export const processMessage = async ({ }, }); - workerLogger.info(`Processing message: ${job.name}`); + workerLogger.info(`${chalk.yellowBright(`Processing message: ${job.name}`)}`); let workerCtx: AutumnContext | undefined; @@ -244,6 +246,18 @@ export const processMessage = async ({ }); return; } + + if (job.name === JobName.ExpireLockReceipt) { + if (!ctx) { + workerLogger.error("No context found for expire lock receipt job"); + return; + } + await expireLock({ + ctx, + payload: job.data, + }); + return; + } }; try { diff --git a/server/src/queue/queueUtils.ts b/server/src/queue/queueUtils.ts index 2c544edc0..969986d9f 100644 --- a/server/src/queue/queueUtils.ts +++ b/server/src/queue/queueUtils.ts @@ -1,6 +1,8 @@ import type { AppEnv, EventInsert, Price } from "@autumn/shared"; +import type { SQSClient } from "@aws-sdk/client-sqs"; import { SendMessageCommand } from "@aws-sdk/client-sqs"; import { generateId } from "@server/utils/genUtils"; +import type { Queue as BullMqQueue } from "bullmq"; import { isHatchetEnabled } from "@/external/hatchet/initHatchet.js"; import { type VerifyCacheInput, @@ -61,14 +63,21 @@ export interface Payloads { env: string; source: string; }; + [JobName.ExpireLockReceipt]: { + orgId: string; + env: AppEnv; + customerId: string; + lockKey: string; + hashedKey: string; + }; [key: string]: unknown; } // Lazy load queue implementations based on environment let queueImplementation: "sqs" | "bullmq" | null = null; -let sqsClient: any = null; +let sqsClient: SQSClient | null = null; let sqsQueueUrl: string | null = null; -let bullmqQueue: any = null; +let bullmqQueue: BullMqQueue | null = null; const initializeQueue = async () => { if (queueImplementation) return; @@ -97,13 +106,13 @@ export const addTaskToQueue = async ({ jobName, payload, messageGroupId, - messageDeduplicationId, + generateDeduplicationId, delayMs, }: { jobName: T; payload: Payloads[T]; messageGroupId?: string; - messageDeduplicationId?: string; + generateDeduplicationId?: boolean; delayMs?: number; }) => { await initializeQueue(); @@ -111,7 +120,10 @@ export const addTaskToQueue = async ({ if (queueImplementation === "sqs") { // SQS implementation const isFifoQueue = sqsQueueUrl?.endsWith(".fifo"); + const messageId = + generateDeduplicationId === false ? undefined : generateId("job"); const message = { + ...(messageId && { id: messageId }), name: jobName as string, data: payload, }; @@ -125,18 +137,16 @@ export const addTaskToQueue = async ({ QueueUrl: sqsQueueUrl!, MessageBody: JSON.stringify(message), ...(delaySeconds && { DelaySeconds: delaySeconds }), - // FIFO queues require MessageGroupId and MessageDeduplicationId + // FIFO queues require MessageGroupId. Content-based deduplication uses the body. ...(isFifoQueue && { MessageGroupId: messageGroupId || generateId("msg"), - // Use provided deduplication ID or generate random (fallback) - MessageDeduplicationId: messageDeduplicationId || generateId("dedup"), }), }); - await sqsClient.send(command); + await sqsClient!.send(command); } else { // BullMQ implementation (ignores messageGroupId) - await bullmqQueue.add(jobName as string, payload, { + await bullmqQueue!.add(jobName as string, payload, { delay: delayMs, }); } diff --git a/server/src/queue/workflows.ts b/server/src/queue/workflows.ts index d810458c3..53110a573 100644 --- a/server/src/queue/workflows.ts +++ b/server/src/queue/workflows.ts @@ -1,5 +1,7 @@ import type { AppEnv } from "@autumn/shared"; import { logger } from "better-auth"; +import { createSchedule } from "@/external/aws/eventbridge/eventBridgeUtils.js"; +import { generateId } from "@/utils/genUtils.js"; import { JobName } from "./JobName.js"; import { addTaskToQueue, runHatchetWorkflow } from "./queueUtils.js"; @@ -72,9 +74,22 @@ export type StoreDeferredInvoiceLineItemsPayload = { billingLineItems: unknown[]; }; +export type ExpireLockReceiptPayload = { + orgId: string; + env: AppEnv; + customerId: string; + lockKey: string; + hashedKey: string; +}; + // ============ Workflow Registry ============ -type WorkflowRunner = "sqs" | "hatchet"; +type WorkflowRunner = "sqs" | "hatchet" | "eventbridge"; + +/** Required options for EventBridge scheduled workflows */ +export type EventBridgeScheduleOptions = { + scheduleAt: Date; +}; type WorkflowConfig = { jobName: JobName; @@ -122,6 +137,11 @@ const workflowRegistry = { jobName: JobName.StoreDeferredInvoiceLineItems, runner: "sqs", } as WorkflowConfig, + + expireLockReceipt: { + jobName: JobName.ExpireLockReceipt, + runner: "eventbridge", + } as WorkflowConfig, } as const; // ============ Type Utilities ============ @@ -135,6 +155,7 @@ type PayloadFor = type TriggerOptions = { delayMs?: number; metadata?: Record; + scheduleAt?: Date; }; // ============ Generic Trigger Function (internal) ============ @@ -157,6 +178,25 @@ const triggerWorkflow = async ({ delayMs: options?.delayMs, metadata: options?.metadata, }); + } else if (config.runner === "eventbridge") { + if (!options?.scheduleAt) { + throw new Error( + `scheduleAt is required for eventbridge workflow: ${name}`, + ); + } + const sqsMessageBody = JSON.stringify({ + name: config.jobName, + data: payload, + }); + // Schedule name derived from hashed_key for deterministic lookup on cancel + const schedulePayload = payload as ExpireLockReceiptPayload; + const scheduleName = `lock-${schedulePayload.hashedKey}`; + await createSchedule({ + scheduleName, + scheduleAt: options.scheduleAt, + sqsMessageBody, + messageGroupId: generateId("mg"), + }); } else { try { await addTaskToQueue({ @@ -214,4 +254,16 @@ export const workflows = { payload, options, }), + + triggerExpireLockReceipt: ( + payload: ExpireLockReceiptPayload, + scheduleOptions: EventBridgeScheduleOptions, + ) => + triggerWorkflow({ + name: "expireLockReceipt", + payload, + options: { + scheduleAt: scheduleOptions.scheduleAt, + }, + }), }; diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 2c7e3a958..032c27210 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -6,59 +6,59 @@ import { createStripeCli } from "@/external/connect/createStripeCli"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; const testStripeCustomerWithGBP = async ({ - ctx, - autumn, + ctx, + autumn, }: { - ctx: AutumnContext; - autumn: AutumnInt; + ctx: AutumnContext; + autumn: AutumnInt; }) => { - const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); - // 1. Create Stripe customer - const stripeCus = await stripeCli.customers.create({ - email: "test-gbp@example.com", - name: "GBP Test Customer", - }); + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + // 1. Create Stripe customer + const stripeCus = await stripeCli.customers.create({ + email: "test-gbp@example.com", + name: "GBP Test Customer", + }); - const paymentMethod = await stripeCli.paymentMethods.create({ - type: "card", - card: { - token: "tok_visa", - }, - }); + const paymentMethod = await stripeCli.paymentMethods.create({ + type: "card", + card: { + token: "tok_visa", + }, + }); - await stripeCli.paymentMethods.attach(paymentMethod.id, { - customer: stripeCus.id, - }); + await stripeCli.paymentMethods.attach(paymentMethod.id, { + customer: stripeCus.id, + }); - const subscription = await stripeCli.subscriptions.create({ - customer: stripeCus.id, - items: [ - { - price_data: { - currency: "gbp", - unit_amount: 1000, - recurring: { - interval: "month", - interval_count: 1, - }, - product: "prod_U5XwDFBQB4TQJ7", - }, - quantity: 1, - }, - ], - default_payment_method: paymentMethod.id, - }); + const subscription = await stripeCli.subscriptions.create({ + customer: stripeCus.id, + items: [ + { + price_data: { + currency: "gbp", + unit_amount: 1000, + recurring: { + interval: "month", + interval_count: 1, + }, + product: "prod_U5XwDFBQB4TQJ7", + }, + quantity: 1, + }, + ], + default_payment_method: paymentMethod.id, + }); - console.log("Subscription created", subscription); + console.log("Subscription created", subscription); - // 2. Create Autumn customer with stripe_id - const autumnCus = await autumn.customers.create({ - id: "gbp-test-customer", - name: "GBP Test Customer", - email: "test-gbp@example.com", - stripe_id: stripeCus.id, - }); - console.log("Created:", { stripeCus: stripeCus.id, autumnCus }); + // 2. Create Autumn customer with stripe_id + const autumnCus = await autumn.customers.create({ + id: "gbp-test-customer", + name: "GBP Test Customer", + email: "test-gbp@example.com", + stripe_id: stripeCus.id, + }); + console.log("Created:", { stripeCus: stripeCus.id, autumnCus }); }; /** @@ -73,17 +73,20 @@ const testStripeCustomerWithGBP = async ({ * - Discount ID unchanged (same di_xxx — carried over via { discount: id }) * - Discount end timestamp unchanged (duration not reset) */ -test.concurrent(`${chalk.yellowBright("immediate-switch-discounts 3: upgrade carries over discount when coupon is deleted")}`, async () => { - const customerId = "temp"; +test.concurrent( + `${chalk.yellowBright("immediate-switch-discounts 3: upgrade carries over discount when coupon is deleted")}`, + async () => { + const customerId = "temp"; - const { autumnV1, testClockId, ctx } = await initScenario({ - // customerId, - setup: [ - // s.customer({ paymentMethod: "success" }), - // s.products({ list: [pro, premium] }), - ], - actions: [], - }); + const { autumnV1, testClockId, ctx } = await initScenario({ + // customerId, + setup: [ + // s.customer({ paymentMethod: "success" }), + // s.products({ list: [pro, premium] }), + ], + actions: [], + }); - await testStripeCustomerWithGBP({ ctx, autumn: autumnV1 }); -}); + console.log("Hello World"); + }, +); diff --git a/server/tests/balances/testBalanceUtils.ts b/server/tests/balances/testBalanceUtils.ts index d7ef67fd9..6b6b3b04a 100644 --- a/server/tests/balances/testBalanceUtils.ts +++ b/server/tests/balances/testBalanceUtils.ts @@ -1,7 +1,5 @@ import { type ApiCustomer, ApiVersion } from "@autumn/shared"; import { AutumnInt } from "../../src/external/autumn/autumnCli.js"; -import { EventService } from "../../src/internal/api/events/EventService.js"; -import ctx from "../utils/testInitUtils/createTestContext.js"; export const getV2Balance = async ({ customerId, @@ -17,25 +15,3 @@ export const getV2Balance = async ({ return customer.balances[featureId]; }; - -export const getCustomerEvents = async ({ - customerId, -}: { - customerId: string; -}) => { - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - const customer = await autumnV2.customers.get(customerId, { - with_autumn_id: true, - }); - - const events = await EventService.getByCustomerId({ - db: ctx.db, - orgId: ctx.org.id, - internalCustomerId: customer.autumn_id ?? "", - env: ctx.env, - limit: 10000, - }); - - return events; -}; diff --git a/server/tests/balances/track/allocated/track-allocated5.test.ts b/server/tests/balances/track/allocated/track-allocated5.test.ts index a1bfa5a02..f564571fe 100644 --- a/server/tests/balances/track/allocated/track-allocated5.test.ts +++ b/server/tests/balances/track/allocated/track-allocated5.test.ts @@ -1,5 +1,6 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { LegacyVersion, type LimitedItem } from "@autumn/shared"; +import { getCustomerEvents } from "@tests/integration/balances/utils/events/getCustomerEvents.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; @@ -9,7 +10,6 @@ import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; import { timeout } from "../../../utils/genUtils"; -import { getCustomerEvents } from "../../testBalanceUtils"; const userItem = constructFeatureItem({ featureId: TestFeature.Users, diff --git a/server/tests/balances/track/entity-balances/track-entity-balances6.test.ts b/server/tests/balances/track/entity-balances/track-entity-balances6.test.ts index caabf164c..1723b1b34 100644 --- a/server/tests/balances/track/entity-balances/track-entity-balances6.test.ts +++ b/server/tests/balances/track/entity-balances/track-entity-balances6.test.ts @@ -92,7 +92,7 @@ describe(`${chalk.yellowBright(`${testCase}: per-entity overage billing`)}`, () userMessages.included_usage * firstEntities.length, ); - await timeout(4000); + await timeout(5000); }); const user1Usage = 125000; diff --git a/server/tests/balances/track/rollovers/track-rollover4.test.ts b/server/tests/balances/track/rollovers/track-rollover4.test.ts index bc3be933d..4f8725384 100644 --- a/server/tests/balances/track/rollovers/track-rollover4.test.ts +++ b/server/tests/balances/track/rollovers/track-rollover4.test.ts @@ -114,7 +114,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for prepaid messa expect(rollovers?.[0].balance).toBe(rollover); // Verify non-cached customer balance - await timeout(2000); + await timeout(4000); const nonCachedCustomer = await autumn.customers.get(customerId, { skip_cache: "true", }); diff --git a/server/tests/integration/balances/lock/basic/check-with-lock-additional-deduct.test.ts b/server/tests/integration/balances/lock/basic/check-with-lock-additional-deduct.test.ts new file mode 100644 index 000000000..14bff1ee1 --- /dev/null +++ b/server/tests/integration/balances/lock/basic/check-with-lock-additional-deduct.test.ts @@ -0,0 +1,218 @@ +import { test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// Product: hourlyMessages(5) + monthlyMessages(10) = 15 total + +const makeFreeProd = () => { + const hourlyMessages = items.hourlyMessages({ includedUsage: 5 }); + const monthlyMessages = items.monthlyMessages({ includedUsage: 10 }); + return products.base({ + id: "free", + items: [hourlyMessages, monthlyMessages], + }); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// AD-1: lock=8, confirm=11 → remaining=4 +// No unwind, deduct 3 more on top of the lock: 15-8-3=4 +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("additional-deduct AD-1: lock=8 confirm=11 — extra deduction, remaining=4")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-add-deduct-1"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 11, + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 4, + }); + + // lock=8 (lockValue=8), confirm=11 (finalValue=11), delta = 11-8 = 3 + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 3 }, { value: 8 }], + }); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 4, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// AD-2: track=10, lock=-5, confirm=-8 → remaining=13 +// track(10) → balance=5. Negative lock credits 5 (→10). confirm=-8 credits 3 more (→13). +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("additional-deduct AD-2: track=10 lock=-5 confirm=-8 — more credit, remaining=13")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-add-deduct-2"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: -5, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: -8, + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 13, + }); + + // lock=-5 (lockValue=-5), confirm=-8 (finalValue=-8), delta = -8-(-5) = -3 + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -3 }, { value: -5 }, { value: 10 }], + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// AD-3: lock=5, confirm=-2 → remaining=17 +// Cross-zero from positive lock: unwind 5 (→15), then credit 2 (→17) +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("additional-deduct AD-3: lock=5 confirm=-2 — cross-zero to credit, remaining=math.min(15,17)")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-add-deduct-3"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 5, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: -2, + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 15, + }); + + // lock=5 (lockValue=5), confirm=-2 (finalValue=-2), delta = -2-5 = -7 + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -7 }, { value: 5 }], + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// AD-4: track=10, lock=-3, confirm=-8 → remaining=13 +// track(10) → balance=5. Negative lock credits 3 (→8). confirm=-8 credits 5 more (→13). +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("additional-deduct AD-4: track=10 lock=-3 confirm=-8 — additional credit, remaining=13")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-add-deduct-4"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: -3, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: -8, + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 13, + }); + + // lock=-3 (lockValue=-3), confirm=-8 (finalValue=-8), delta = -8-(-3) = -5 + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -5 }, { value: -3 }, { value: 10 }], + }); +}); diff --git a/server/tests/integration/balances/lock/basic/check-with-lock-refund-breakdown.test.ts b/server/tests/integration/balances/lock/basic/check-with-lock-refund-breakdown.test.ts new file mode 100644 index 000000000..3f9e17ddb --- /dev/null +++ b/server/tests/integration/balances/lock/basic/check-with-lock-refund-breakdown.test.ts @@ -0,0 +1,203 @@ +import { test } from "bun:test"; +import { type ApiCustomerV5, ResetInterval } from "@autumn/shared"; +import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectLockReceiptDeleted } from "@tests/integration/balances/utils/lockUtils/expectLockReceiptDeleted.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// Product: hourlyMessages(5) + lifetimeMessages(20) = 25 total + +const makeFreeProd = () => { + const hourlyMessages = items.hourlyMessages({ includedUsage: 5 }); + const lifetimeMessages = items.lifetimeMessages({ includedUsage: 20 }); + return products.base({ + id: "free", + items: [hourlyMessages, lifetimeMessages], + }); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// BD-1: lock=8, confirm=5 → hourly=0, lifetime=20 +// Deduct 8 from hourly first (5), then lifetime (3). Confirm 5, unwind 3 from lifetime. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("refund BD-1: lock=8 confirm=5 — unwind 3 from lifetime bucket")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-breakdown-1"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 5, + }); + + const customer = await autumnV2_1.customers.get(customerId); + + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 20, + breakdown: { + [ResetInterval.Hour]: { included_grant: 5, remaining: 0 }, + [ResetInterval.OneOff]: { included_grant: 20, remaining: 20 }, + }, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// BD-2: lock=8, confirm=0 → hourly=5, lifetime=20 +// Full release: unwind all 8 (3 from lifetime, 5 from hourly) +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("refund BD-2: lock=8 confirm=0 — full release, both buckets restored")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-breakdown-2"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 0, + }); + + const customer = await autumnV2_1.customers.get(customerId); + + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 25, + breakdown: { + [ResetInterval.Hour]: { included_grant: 5, remaining: 5 }, + [ResetInterval.OneOff]: { included_grant: 20, remaining: 20 }, + }, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// BD-3: lock=5, confirm=3 → hourly=2, lifetime=20 +// Lock=5, only deducts from hourly (5). Confirm=3, unwind 2 from hourly only. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("refund BD-3: lock=5 confirm=3 — unwind stays within hourly bucket")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-breakdown-3"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 5, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 3, + }); + + const customer = await autumnV2_1.customers.get(customerId); + + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 22, + breakdown: { + [ResetInterval.Hour]: { included_grant: 5, remaining: 2 }, + [ResetInterval.OneOff]: { included_grant: 20, remaining: 20 }, + }, + }); + + await expectLockReceiptDeleted({ ctx, lockKey: customerId }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// BD-4: lock=8, confirm=8 → hourly=0, lifetime=17 +// finalValue == lockValue → early exit, no deduction step, no finalize event. +// Only 1 event: the original check track (value=8). +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("refund BD-4: lock=8 confirm=8 — no unwind, lifetime bucket reduced")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-breakdown-4"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 8, + }); + + const customer = await autumnV2_1.customers.get(customerId); + + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 17, + breakdown: { + [ResetInterval.Hour]: { included_grant: 5, remaining: 0 }, + [ResetInterval.OneOff]: { included_grant: 20, remaining: 17 }, + }, + }); + + // finalValue == lockValue → early exit, no finalize event emitted; only 1 event (check track) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 8 }], + }); +}); diff --git a/server/tests/integration/balances/lock/basic/check-with-lock-refund.test.ts b/server/tests/integration/balances/lock/basic/check-with-lock-refund.test.ts new file mode 100644 index 000000000..0a681dc30 --- /dev/null +++ b/server/tests/integration/balances/lock/basic/check-with-lock-refund.test.ts @@ -0,0 +1,231 @@ +import { test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// Product: hourlyMessages(5) + monthlyMessages(10) = 15 total + +const makeFreeProd = () => { + const hourlyMessages = items.hourlyMessages({ includedUsage: 5 }); + const monthlyMessages = items.monthlyMessages({ includedUsage: 10 }); + return products.base({ + id: "free", + items: [hourlyMessages, monthlyMessages], + }); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// RF-1: lock=8, confirm=5 → remaining=10 +// Unwind 3 (8 deducted, 5 kept), 15-5=10 +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("refund RF-1: lock=8 confirm=5 — partial keep, remaining=10")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-refund-1"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 5, + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 10, + }); + + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -3 }, { value: 8 }], + }); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 10, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// RF-2: lock=8, confirm=-3 → remaining=18 +// Negative confirm = credit 3 back on top: 15-8 → 7, then unwind 8 and credit 3 → 18 +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("refund RF-2: lock=8 confirm=-3 — credit beyond lock, remaining=18")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-refund-2"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 5, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: -3, + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 13, + }); + + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -11 }, { value: 8 }, { value: 5 }], + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// RF-3: lock=-5, confirm=-3 → remaining=18 +// Negative lock = credit 5 (balance→20), negative confirm=-3 means keep credit of 3 → 18 +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("refund RF-3: lock=-5 confirm=-3 — negative lock with partial release, remaining=18")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-refund-3"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: -5, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: -3, + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 8, + }); + + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 2 }, { value: -5 }, { value: 10 }], + }); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 8, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// RF-4: lock=-5, confirm=3 → remaining=12 +// Negative lock = credit 5 (balance→20), confirm=3 = deduct 3 → 12 +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("refund RF-4: lock=-5 confirm=3 — cross-zero confirm, remaining=12")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-refund-4"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: -5, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 3, + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 2, + }); + + // lock=-5 (lockValue=-5), confirm=3 (finalValue=3), delta = 3-(-5) = 8 + // prior track=10, newest-first: finalize delta, check track, prior track + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 8 }, { value: -5 }, { value: 10 }], + }); +}); diff --git a/server/tests/integration/balances/lock/basic/check-with-lock-release.test.ts b/server/tests/integration/balances/lock/basic/check-with-lock-release.test.ts new file mode 100644 index 000000000..1df280b63 --- /dev/null +++ b/server/tests/integration/balances/lock/basic/check-with-lock-release.test.ts @@ -0,0 +1,170 @@ +import { test } from "bun:test"; +import { type ApiCustomerV5, ResetInterval } from "@autumn/shared"; +import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// Product: hourlyMessages(5) + monthlyMessages(10) = 15 total + +const makeFreeProd = () => { + const hourlyMessages = items.hourlyMessages({ includedUsage: 5 }); + const monthlyMessages = items.monthlyMessages({ includedUsage: 10 }); + return products.base({ + id: "free", + items: [hourlyMessages, monthlyMessages], + }); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// RL-1: lock=8, release → remaining=15 +// Full unwind regardless of lock amount — balance fully restored +// Events: finalize delta = 0-8 = -8, track = 8 +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("release RL-1: lock=8 release — full restore, remaining=15")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-release-1"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "release", + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 15, + }); + + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -8 }, { value: 8 }], + }); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 15, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// RL-2: track=5, lock=8, release → remaining=10 +// Prior usage is preserved; only the lock deduction is unwound +// Events: finalize delta = 0-8 = -8, track = 8, track = 5 +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("release RL-2: track=5 lock=8 release — prior usage kept, remaining=10")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-release-2"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 5, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "release", + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 10, + }); + + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -8 }, { value: 8 }, { value: 5 }], + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// RL-3: lock=8 spanning hourly+lifetime buckets, release → both fully restored +// Deducts 5 from hourly, 3 from lifetime. Release unwinds both. +// Product: hourlyMessages(5) + lifetimeMessages(20) = 25 total +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("release RL-3: lock=8 cross-bucket release — both buckets fully restored")}`, async () => { + const hourlyMessages = items.hourlyMessages({ includedUsage: 5 }); + const lifetimeMessages = items.lifetimeMessages({ includedUsage: 20 }); + const freeProd = products.base({ + id: "free", + items: [hourlyMessages, lifetimeMessages], + }); + + const customerId = "lock-release-4"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "release", + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 25, + breakdown: { + [ResetInterval.Hour]: { included_grant: 5, remaining: 5 }, + [ResetInterval.OneOff]: { included_grant: 20, remaining: 20 }, + }, + }); +}); diff --git a/server/tests/integration/balances/lock/check-with-lock-basic.test.ts b/server/tests/integration/balances/lock/check-with-lock-basic.test.ts deleted file mode 100644 index a5507cbc4..000000000 --- a/server/tests/integration/balances/lock/check-with-lock-basic.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { test } from "bun:test"; -import type { ApiCustomerV5 } from "@autumn/shared"; -import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { items } from "@tests/utils/fixtures/items.js"; -import { products } from "@tests/utils/fixtures/products.js"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; -import chalk from "chalk"; - -// ═══════════════════════════════════════════════════════════════════ -// CHECK: No feature attached -// ═══════════════════════════════════════════════════════════════════ - -test.concurrent(`${chalk.yellowBright("check-with-lock-basic: /check with lock basic")}`, async () => { - const lockKey = "test-lock"; - const hourlyMessages = items.hourlyMessages({ includedUsage: 5 }); - const monthlyMessages = items.monthlyMessages({ includedUsage: 10 }); - const freeProd = products.base({ - id: "free", - items: [hourlyMessages, monthlyMessages], - }); - - const { customerId, autumnV2, ctx } = await initScenario({ - customerId: "check-no-feature", - setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], - actions: [s.attach({ productId: freeProd.id })], - }); - - await deleteLock({ - ctx, - lockKey, - }); - - const firstResult = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - required_balance: 8, - lock: { - enabled: true, - key: lockKey, - }, - }); - - // Release lock - await autumnV2.balances.finalize({ - finalize_action: "confirm", - overwrite_value: 4, - lock_key: lockKey, - }); - - const customer = await autumnV2.customers.get(customerId); - - console.log("Message balance:", customer.balances[TestFeature.Messages]); -}); diff --git a/server/tests/integration/balances/lock/check-with-lock-concurrent-stress.test.ts b/server/tests/integration/balances/lock/check-with-lock-concurrent-stress.test.ts new file mode 100644 index 000000000..e53fb7f2b --- /dev/null +++ b/server/tests/integration/balances/lock/check-with-lock-concurrent-stress.test.ts @@ -0,0 +1,198 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// Stress test: 1000 concurrent (check-with-lock + finalize) pairs that +// intentionally cross the Action1 → Credits boundary. +// +// Credit system: Action1 → Credits (credit_cost = 0.2) +// i.e. 1 Action1 unit costs 0.2 Credits +// +// Product: Action1(1000) + Credits(2000) +// +// Each pair: +// - lock_value: random decimal in [0.51, 2.99] (Action1 units) +// - override_value: random decimal in [0.10, 3.50] (mix of refunds & extra deductions) +// +// With 1000 pairs and average override ≈ 1.80, the expected total override is +// ~1800 Action1 units — well above the 1000 included_usage. The excess spills +// into Credits at 0.2 credits per action1 unit. +// +// Expected final state (Decimal.js precision): +// totalOverride = sum(all override_values) +// action1Remaining = max(0, 1000 - totalOverride) +// creditsConsumed = max(0, totalOverride - 1000) × 0.2 +// creditsRemaining = 2000 - creditsConsumed +// +// Both cached and non-cached (DB-synced) balances are asserted. +// ───────────────────────────────────────────────────────────────────────────── + +const NUM_PAIRS = 1000; + +const INITIAL_ACTION1 = 1000; +const INITIAL_CREDITS = 2000; +const CREDIT_COST = 0.2; // 1 action1 unit = 0.2 credits + +const randomDecimal = (min: number, max: number): Decimal => + new Decimal(Math.random() * (max - min) + min).toDecimalPlaces(2); + +test( + `${chalk.yellowBright(`lock-stress: ${NUM_PAIRS} concurrent (check+finalize) pairs — crosses Action1→Credits boundary`)}`, + async () => { + const freeProd = products.base({ + id: "free", + items: [ + items.free({ + featureId: TestFeature.Action1, + includedUsage: INITIAL_ACTION1, + }), + items.monthlyCredits({ includedUsage: INITIAL_CREDITS }), + ], + }); + + const customerId = "lock-stress-1"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // ── Generate all (lock_value, override_value, lock_key) triples up front ── + + type Pair = { + lockKey: string; + lockValue: Decimal; + overrideValue: Decimal; + }; + + let totalOverride = new Decimal(0); + + const pairs: Pair[] = Array.from({ length: NUM_PAIRS }, (_, i) => { + const lockValue = randomDecimal(0.51, 2.99); + const overrideValue = randomDecimal(0.1, 3.5); + totalOverride = totalOverride.plus(overrideValue); + return { + lockKey: `${customerId}-lock-${i}`, + lockValue, + overrideValue, + }; + }); + + // Clean up any stale lock receipts from previous runs + await Promise.all(pairs.map(({ lockKey }) => deleteLock({ ctx, lockKey }))); + + // ── Fire all (check + finalize) pairs concurrently ── + // Each pair is sequential within itself (check must complete before its own + // finalize), but all 1000 pairs run fully in parallel with each other. + + const startTime = Date.now(); + + await Promise.all( + pairs.map(async ({ lockKey, lockValue, overrideValue }) => { + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: lockValue.toNumber(), + lock: { enabled: true, key: lockKey }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: overrideValue.toNumber(), + }); + }), + ); + + console.log( + `[lock-stress] ${NUM_PAIRS} (check+finalize) pairs completed in ${Date.now() - startTime}ms`, + ); + + // ── Compute expected balances ── + + // Action1 is exhausted first; overflow spills into Credits at CREDIT_COST per unit. + const action1Overflow = Decimal.max( + 0, + totalOverride.minus(INITIAL_ACTION1), + ); + const creditsConsumed = action1Overflow.mul(CREDIT_COST); + + const expectedAction1Remaining = Decimal.max( + 0, + new Decimal(INITIAL_ACTION1).minus(totalOverride), + ) + .toDecimalPlaces(2) + .toNumber(); + + const expectedCreditsRemaining = new Decimal(INITIAL_CREDITS) + .minus(creditsConsumed) + .toDecimalPlaces(2) + .toNumber(); + + console.log( + `[lock-stress] totalOverride=${totalOverride.toFixed(2)}, ` + + `action1Overflow=${action1Overflow.toFixed(2)}, ` + + `creditsConsumed=${creditsConsumed.toFixed(2)}`, + ); + console.log( + `[lock-stress] expected action1=${expectedAction1Remaining}, credits=${expectedCreditsRemaining}`, + ); + + // ── Assert cached balances ── + + const customer = await autumnV2_1.customers.get(customerId); + + // Round to 2dp to absorb float accumulation across 1000 additions + const actualAction1Cached = new Decimal( + customer.balances[TestFeature.Action1]?.remaining ?? 0, + ) + .toDecimalPlaces(2) + .toNumber(); + + const actualCreditsCached = new Decimal( + customer.balances[TestFeature.Credits]?.remaining ?? 0, + ) + .toDecimalPlaces(2) + .toNumber(); + + expect(actualAction1Cached).toBe(expectedAction1Remaining); + expect(actualCreditsCached).toBe(expectedCreditsRemaining); + + // ── Assert non-cached (DB-synced) balances ── + + await timeout(5000); + + const customerDb = await autumnV2_1.customers.get( + customerId, + { skip_cache: "true" }, + ); + + const actualAction1Db = new Decimal( + customerDb.balances[TestFeature.Action1]?.remaining ?? 0, + ) + .toDecimalPlaces(2) + .toNumber(); + + const actualCreditsDb = new Decimal( + customerDb.balances[TestFeature.Credits]?.remaining ?? 0, + ) + .toDecimalPlaces(2) + .toNumber(); + + expect(actualAction1Db).toBe(expectedAction1Remaining); + expect(actualCreditsDb).toBe(expectedCreditsRemaining); + }, + { timeout: 120_000 }, +); diff --git a/server/tests/integration/balances/lock/check-with-lock-credit-system.test.ts b/server/tests/integration/balances/lock/check-with-lock-credit-system.test.ts new file mode 100644 index 000000000..ce24db3db --- /dev/null +++ b/server/tests/integration/balances/lock/check-with-lock-credit-system.test.ts @@ -0,0 +1,877 @@ +import { test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectLockReceiptDeleted } from "@tests/integration/balances/utils/lockUtils/expectLockReceiptDeleted.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; +import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// Credit system schema: +// Action1 → Credits (credit_cost=0.2) +// Action3 → Credits2 (credit_cost=1.4) +// ───────────────────────────────────────────────────────────────────────────── + +const makeAction1CreditsProd = () => + products.base({ + id: "free", + items: [ + items.free({ featureId: TestFeature.Action1, includedUsage: 100 }), + items.monthlyCredits({ includedUsage: 200 }), + ], + }); + +const makeAction3Credits2Prod = () => + products.base({ + id: "free", + items: [ + items.free({ featureId: TestFeature.Action3, includedUsage: 60 }), + items.free({ featureId: TestFeature.Credits2, includedUsage: 100 }), + ], + }); + +// ───────────────────────────────────────────────────────────────────────────── +// CS-1: confirm override_value=0 — full refund via confirm (not release) +// action1=100. lock=8 → action1=92. confirm override_value=0 → delta=-8, +// full unwind of receipt. action1=100, credits=200. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-credit CS-1: lock=8 confirm override_value=0 — full refund via confirm, not release")}`, async () => { + const freeProd = makeAction1CreditsProd(); + const customerId = "lock-credit-1"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 0, + }); + + // delta = 0 - 8 = -8 → full unwind, action1 fully restored + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Action1, + remaining: 100, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 200, + }); + + // 2 events: finalize (-8), check (8) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -8 }, { value: 8 }], + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CS-2: cross-boundary lock=8, confirm=12 — extra deduction into credits +// track(95): action1=5. Lock deducts 5 from action1 + 3 overflow (0.6 credits). +// confirm=12: finalValue=12, delta=+4 → deduct 4 more from credits (0.8 credits). +// action1=0, credits = 200 - 0.6 - 0.8 = 198.6. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-credit CS-2: cross-boundary lock=8 confirm=12 — extra deduction into credits")}`, async () => { + const freeProd = makeAction1CreditsProd(); + const customerId = "lock-credit-2"; + + const { + autumnV2_1, + ctx, + ctx: { features }, + } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 95, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 12, + }); + + const creditFeature = features.find((f) => f.id === TestFeature.Credits)!; + const lockCreditCost = getCreditCost({ + featureId: TestFeature.Action1, + creditSystem: creditFeature, + amount: 3, // overflow during lock: 8 - 5 remaining = 3 + }); + const extraCreditCost = getCreditCost({ + featureId: TestFeature.Action1, + creditSystem: creditFeature, + amount: 4, // confirm delta: 12 - 8 = 4 more units + }); + const expectedCredits = new Decimal(200) + .sub(lockCreditCost) + .sub(extraCreditCost) + .toNumber(); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Action1, + remaining: 0, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: expectedCredits, + }); + + // delta = 12 - 8 = 4. Events: finalize (+4), check (8), prior track (95) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 4 }, { value: 8 }, { value: 95 }], + }); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Action1, + remaining: 0, + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Credits, + remaining: expectedCredits, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CS-3: cross-boundary lock=8, confirm=3 — partial unwind +// track(95): action1=5. Lock deducts 5 from action1 + 3 overflow (0.6 credits). +// confirm=3: delta=-5 → unwind LIFO: restore 3 from credits (→200), restore 2 from action1 (→2). +// action1=2, credits=200. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-credit CS-3: cross-boundary lock=8 confirm=3 — unwind restores credits then action1")}`, async () => { + const freeProd = makeAction1CreditsProd(); + const customerId = "lock-credit-3"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 95, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 3, + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Action1, + remaining: 2, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 200, + }); + + // delta = 3 - 8 = -5. Events: finalize (-5), check (8), prior track (95) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -5 }, { value: 8 }, { value: 95 }], + }); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Action1, + remaining: 2, + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Credits, + remaining: 200, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CS-4: lock within action1, confirm blows past action1 entirely into credits +// action1=100. lock=10 → action1=90. confirm override_value=115: delta=+105. +// Deduct 105 more: exhaust action1 (90 → 0), overflow 15 → 15×0.2=3 credits. +// action1=0, credits=200-3=197. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-credit CS-4: lock within action1, confirm=115 blows past action1 into credits")}`, async () => { + const freeProd = makeAction1CreditsProd(); + const customerId = "lock-credit-4"; + + const { + autumnV2_1, + ctx, + ctx: { features }, + } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: 10, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 115, + }); + + // Lock deducted 10 from action1 (→90). Confirm delta=+105: + // exhaust remaining 90 from action1 (→0), then 15 overflow → 15×0.2=3 credits. + const creditFeature = features.find((f) => f.id === TestFeature.Credits)!; + const overflowCreditCost = getCreditCost({ + featureId: TestFeature.Action1, + creditSystem: creditFeature, + amount: 15, + }); + const expectedCredits = new Decimal(200).sub(overflowCreditCost).toNumber(); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Action1, + remaining: 0, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: expectedCredits, + }); + + // 2 events: finalize (+105), check (10) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 105 }, { value: 10 }], + }); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Action1, + remaining: 0, + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Credits, + remaining: expectedCredits, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CS-5: decimal lock=27.89, confirm=12.45 — partial unwind within action1 +// action1=100. lock=27.89 all from action1. confirm=12.45 → unwind 15.44 from action1. +// action1=100-12.45=87.55, credits=200. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-credit CS-5: decimal lock=27.89 confirm=12.45 — partial unwind within action1")}`, async () => { + const freeProd = makeAction1CreditsProd(); + const customerId = "lock-credit-5"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: 27.89, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 12.45, + }); + + const expectedAction1 = new Decimal(100).sub(12.45).toNumber(); + const delta = new Decimal(12.45).sub(27.89).toNumber(); // -15.44 + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Action1, + remaining: expectedAction1, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 200, + }); + + // 2 events: finalize (-15.44), check (27.89) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: delta }, { value: 27.89 }], + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CS-6: credits2 cross-boundary lock=10, confirm=3 — partial unwind +// action3(60) + credits2(100). track(55): action3=5. +// Lock=10: deducts 5 from action3 (→0), overflow=5 → 5×1.4=7 credits from credits2 (→93). +// confirm=3: delta=-7 → unwind LIFO: restore 5 units from credits2 (7 credits →100), restore 2 from action3 (→2). +// action3=2, credits2=100. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-credit CS-6: action3→credits2 cross-boundary lock=10 confirm=3 — partial unwind restores credits2 then action3")}`, async () => { + const freeProd = makeAction3Credits2Prod(); + const customerId = "lock-credit-6"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Action3, + value: 55, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Action3, + required_balance: 10, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 3, + }); + + const delta = new Decimal(3).sub(10).toNumber(); // -7 + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Action3, + remaining: 2, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits2, + remaining: 100, + }); + + // delta=-7. Events: finalize (-7), check (10), prior track (55) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: delta }, { value: 10 }, { value: 55 }], + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CS-7: credits2 cross-boundary release — credits2 fully restored +// action3(60) + credits2(100). track(55): action3=5. +// Lock=10: deducts 5 from action3 (→0), overflow=5 → 7 credits from credits2 (→93). +// release: delta=-10 → full unwind. action3=5, credits2=100. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-credit CS-7: action3→credits2 cross-boundary release — credits2 fully restored")}`, async () => { + const freeProd = makeAction3Credits2Prod(); + const customerId = "lock-credit-7"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Action3, + value: 55, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Action3, + required_balance: 10, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "release", + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Action3, + remaining: 5, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits2, + remaining: 100, + }); + + // release: delta=0-10=-10. Events: finalize (-10), check (10), prior track (55) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -10 }, { value: 10 }, { value: 55 }], + }); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Action3, + remaining: 5, + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Credits2, + remaining: 100, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CS-8: cross-boundary release — credits fully restored +// action1(100) + credits(200). track(95): action1=5. +// Lock=8: deducts 5 from action1 + 3 overflow (0.6 credits). Release → full unwind. +// action1=5, credits=200. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-credit CS-8: cross-boundary release — credits fully restored")}`, async () => { + const freeProd = makeAction1CreditsProd(); + const customerId = "lock-credit-8"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 95, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "release", + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Action1, + remaining: 5, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 200, + }); + + // release: delta=0-8=-8. Events: finalize (-8), check (8), prior track (95) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -8 }, { value: 8 }, { value: 95 }], + }); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Action1, + remaining: 5, + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Credits, + remaining: 200, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CS-9: override_value > lock, lock already crossed into credits, confirm goes deeper +// action1(100) + credits(200). track(95): action1=5. +// Lock=8: deducts 5 from action1 (→0) + 3 overflow → 3×0.2=0.6 credits (→199.4). +// confirm override_value=20: delta=+12 → deduct 12 more units, all from credits (12×0.2=2.4). +// action1=0, credits=199.4-2.4=197. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-credit CS-9: cross-boundary lock=8 confirm=20 — override_value > lock, extra credit deduction")}`, async () => { + const freeProd = makeAction1CreditsProd(); + const customerId = "lock-credit-9"; + + const { + autumnV2_1, + ctx, + ctx: { features }, + } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 95, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 20, + }); + + // Lock deducted: 5 from action1 + 3 overflow (0.6 credits). + // Confirm delta = 20 - 8 = 12 more units, action1 is already 0, all go to credits: 12×0.2=2.4. + const creditFeature = features.find((f) => f.id === TestFeature.Credits)!; + const lockOverflowCost = getCreditCost({ + featureId: TestFeature.Action1, + creditSystem: creditFeature, + amount: 3, + }); + const confirmExtraCost = getCreditCost({ + featureId: TestFeature.Action1, + creditSystem: creditFeature, + amount: 12, + }); + const expectedCredits = new Decimal(200) + .sub(lockOverflowCost) + .sub(confirmExtraCost) + .toNumber(); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Action1, + remaining: 0, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: expectedCredits, + }); + + // delta = 20 - 8 = 12. Events: finalize (+12), check (8), prior track (95) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 12 }, { value: 8 }, { value: 95 }], + }); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Action1, + remaining: 0, + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Credits, + remaining: expectedCredits, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CS-10: confirm with no override_value — early exit, receipt deleted, balance unchanged +// action1(100) + credits(200). track(95): action1=5. +// Lock=8: deducts 5 from action1 (→0) + 3 overflow → 0.6 credits (→199.4). +// confirm with no override_value → finalValue defaults to lockValue (8) → early exit. +// Receipt is deleted. action1=0, credits=199.4. No finalize event emitted. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-credit CS-10: confirm no override_value — early exit, receipt deleted, balance + events unchanged")}`, async () => { + const freeProd = makeAction1CreditsProd(); + const customerId = "lock-credit-10"; + + const { + autumnV2_1, + ctx, + ctx: { features }, + } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 95, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + // No override_value → finalValue === lockValue (8) → early exit + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + }); + + // Balances unchanged from what the lock left + const creditFeature = features.find((f) => f.id === TestFeature.Credits)!; + const lockOverflowCost = getCreditCost({ + featureId: TestFeature.Action1, + creditSystem: creditFeature, + amount: 3, // overflow during lock: 8 - 5 remaining = 3 + }); + const expectedCredits = new Decimal(200).sub(lockOverflowCost).toNumber(); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Action1, + remaining: 0, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: expectedCredits, + }); + + // Early exit → no finalize event. Only check (8) + prior track (95). + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 8 }, { value: 95 }], + }); + + // Receipt must be deleted after finalize + await expectLockReceiptDeleted({ ctx, lockKey: customerId }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CS-11: direct credits lock=30, confirm=20 — partial refund +// Customer has only a Credits bucket (200), no Action1. +// check(feature_id=Credits, required_balance=30) → deducts 30 credits directly (1:1). +// confirm(override_value=20) → delta=-10 → unwind 10 → credits=180. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-credit CS-11: direct credits lock=30 confirm=20 — partial refund, credits=180")}`, async () => { + // Product with only a Credits bucket — no Action1 item. Credits used as the + // feature directly (1:1 deduction, no credit-cost conversion). + const freeProd = products.base({ + id: "free", + items: [items.monthlyCredits({ includedUsage: 200 })], + }); + const customerId = "lock-credit-11"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Credits, + required_balance: 30, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 20, + }); + + // delta = 20 - 30 = -10 → 10 credits refunded + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 180, + }); + + // Events: finalize (-10), check (30) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -10 }, { value: 30 }], + }); + + // DB balance + await timeout(3000); + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Credits, + remaining: 180, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CS-12: direct credits lock=30, confirm=45 — extra deduction +// Customer has only a Credits bucket (200), no Action1. +// check(feature_id=Credits, required_balance=30) → credits=170. +// confirm(override_value=45) → delta=+15 → deduct 15 more → credits=155. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-credit CS-12: direct credits lock=30 confirm=45 — extra deduction, credits=155")}`, async () => { + const freeProd = products.base({ + id: "free", + items: [items.monthlyCredits({ includedUsage: 200 })], + }); + const customerId = "lock-credit-12"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Credits, + required_balance: 30, + lock: { enabled: true, key: customerId }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 45, + }); + + // delta = 45 - 30 = +15 → 15 more credits deducted → 200 - 45 = 155 + const expectedCredits = new Decimal(200).sub(45).toNumber(); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: expectedCredits, + }); + + // Events: finalize (+15), check (30) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 15 }, { value: 30 }], + }); + + // DB balance + await timeout(3000); + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Credits, + remaining: expectedCredits, + }); +}); diff --git a/server/tests/integration/balances/lock/check-with-lock-edge-cases.test.ts b/server/tests/integration/balances/lock/check-with-lock-edge-cases.test.ts new file mode 100644 index 000000000..3075b7073 --- /dev/null +++ b/server/tests/integration/balances/lock/check-with-lock-edge-cases.test.ts @@ -0,0 +1,365 @@ +import { test } from "bun:test"; +import { type ApiCustomerV5, ResetInterval } from "@autumn/shared"; +import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ───────────────────────────────────────────────────────────────────────────── +// Edge case: lock spans multiple entitlement types, then the product is upgraded +// mid-flight (lock held → confirm after upgrade). +// +// Setup: +// addonProd: lifetimeMessages(100) — never resets, add-on +// freeProd: monthlyMessages(50) — resets monthly, customer product +// proProd: monthlyMessages(80) — resets monthly, upgrade target +// +// Initial state: lifetime=100, monthly(free)=50. Total=150. +// +// Check lock=60 (no entity_id): +// Deduction order: monthly(free) → lifetime (monthly exhausted first). +// monthly: 50→0, lifetime: 100→90. +// Receipt records: [monthly:50, lifetime:10]. Total after check=90. +// +// Upgrade free→pro: +// Old monthly(free) entitlement is replaced by monthly(pro)=80. +// Lifetime addon bucket is unaffected (persists across upgrade). +// State after upgrade: lifetime=90, monthly(pro)=80. Total=170. +// +// Key: the lock receipt still references the OLD monthly entitlement ID (now gone) +// and the lifetime entitlement ID. On finalize: +// +// EC-1 (confirm override=57, delta=-3 → LIFO refund of 3): +// LIFO unwinds last bucket first = lifetime. Restore 3 → lifetime=93. +// monthly(pro) is untouched (not in receipt). Total=173. +// +// EC-2 (confirm override=63, delta=+3 → additional deduction of 3): +// Continue deducting from lifetime (last bucket). lifetime=90→87. +// monthly(pro) is untouched (not in receipt). Total=167. +// ───────────────────────────────────────────────────────────────────────────── + +const makeAddonProd = () => + products.base({ + id: "addon", + isAddOn: true, + items: [items.lifetimeMessages({ includedUsage: 100 })], + }); + +const makeFreeProd = () => + products.base({ + id: "free", + items: [items.monthlyMessages({ includedUsage: 50 })], + }); + +const makeProProd = () => + products.base({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 80 })], + }); + +// ───────────────────────────────────────────────────────────────────────────── +// EC-3: lock on free-only product, upgrade to pro, track on pro, confirm with +// refund — skipped unwind redirects refund onto current (pro) entitlement. +// +// Setup: freeProd monthlyMessages(50) only (no lifetime addon). +// proProd monthlyMessages(80). +// +// check lock=40 on free: monthly(free)=50→10. Receipt: [monthly(free):40]. +// Upgrade free→pro: monthly(free) entitlement replaced by monthly(pro)=80. +// Receipt still references old monthly(free) ID (now gone). Total=80. +// track 20 on pro: monthly(pro)=80→60. +// confirm override=30 → delta = 30-40 = -10 → unwind 10. +// LIFO: try monthly(free) — not found, skip. +// remaining_signed_unwind_value = -10 (positive lock, so negate). +// effective_additional = 0 + (-10) = -10 → refund 10 onto monthly(pro)=60→70. +// Final: monthly(pro)=70. Total=70. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-edge EC-3: lock on free, upgrade to pro, track, confirm refund — skipped unwind redirects onto pro entitlement")}`, async () => { + const freeProd = makeFreeProd(); + const proProd = makeProProd(); + const customerId = "lock-edge-3"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd, proProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey }); + + // check lock=40: deducts all 40 from monthly(free)=50→10. + // Receipt: [monthly(free):40] + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 40, + lock: { enabled: true, key: lockKey }, + }); + + const afterCheck = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterCheck, + featureId: TestFeature.Messages, + remaining: 10, + }); + + // Upgrade free→pro. monthly(free) entitlement replaced by monthly(pro)=80. + // Lock receipt still references the now-gone monthly(free) ID. + await autumnV2_1.attach({ + customer_id: customerId, + product_id: proProd.id, + }); + + const afterUpgrade = + await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterUpgrade, + featureId: TestFeature.Messages, + remaining: 80, + }); + + // Track 20 on pro to consume some balance, giving room for the refund. + // monthly(pro)=80→60. + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 20, + }); + + // confirm override=30 → delta = 30-40 = -10 → unwind 10. + // LIFO: monthly(free) not found → skip, remaining_signed_unwind_value=-10. + // effective_additional = 0 + (-10) = -10 → refund 10 onto monthly(pro)=60→70. + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 30, + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 70, + }); + + // Events newest-first: finalize(-10), track(20), check(40) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -10 }, { value: 20 }, { value: 40 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 70, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EC-1: lock=60 (crosses monthly→lifetime), upgrade free→pro, confirm=57 (delta=-3) +// LIFO unwind restores 3 to lifetime. monthly(pro) stays at full 80. +// Final: lifetime=93, monthly(pro)=80. Total=173. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-edge EC-1: lock crosses monthly→lifetime, upgrade mid-flight, confirm with refund — monthly(pro) untouched, lifetime refunded")}`, async () => { + const addonProd = makeAddonProd(); + const freeProd = makeFreeProd(); + const proProd = makeProProd(); + const customerId = "lock-edge-1"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [addonProd, freeProd, proProd] }), + ], + actions: [ + // Attach addon first (lifetime bucket), then free (monthly bucket) + s.attach({ productId: addonProd.id }), + s.attach({ productId: freeProd.id }), + ], + }); + + await deleteLock({ ctx, lockKey }); + + // Check lock=60: exhausts monthly(free)=50, then deducts 10 from lifetime. + // Receipt: [monthly(free):50, lifetime:10] + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 60, + lock: { enabled: true, key: lockKey }, + }); + + // Verify state after check: total=90 (lifetime=90, monthly=0) + const afterCheck = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterCheck, + featureId: TestFeature.Messages, + remaining: 90, + }); + + // Upgrade: free→pro. New monthly entitlement (80) replaces old monthly (0). + // Lifetime addon is unaffected. Lock receipt still references old monthly ID. + await autumnV2_1.attach({ + customer_id: customerId, + product_id: proProd.id, + }); + + // Verify state after upgrade: lifetime=90, monthly(pro)=80. Total=170. + const afterUpgrade = + await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterUpgrade, + featureId: TestFeature.Messages, + remaining: 170, + }); + + // Confirm override=57 → delta = 57-60 = -3. + // LIFO: restore 3 to lifetime (last bucket touched). lifetime=90→93. + // monthly(pro) not in receipt → stays at 80. + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 57, + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 173, + }); + + // Events newest-first: finalize(-3), check(60). + // The upgrade does not emit message events (free product, no billing). + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -3 }, { value: 60 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 173, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EC-2: lock=60 (crosses monthly→lifetime), upgrade free→pro, confirm=63 (delta=+3) +// Additional deduction of 3 from lifetime (last bucket). monthly(pro) untouched. +// Final: lifetime=87, monthly(pro)=80. Total=167. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-edge EC-2: lock crosses monthly→lifetime, upgrade mid-flight, confirm with extra deduction — monthly deducted")}`, async () => { + const addonProd = makeAddonProd(); + const freeProd = makeFreeProd(); + const proProd = makeProProd(); + const customerId = "lock-edge-2"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [addonProd, freeProd, proProd] }), + ], + actions: [ + s.attach({ productId: addonProd.id }), + s.attach({ productId: freeProd.id }), + ], + }); + + await deleteLock({ ctx, lockKey }); + + // Check lock=60: exhausts monthly(free)=50, deducts 10 from lifetime. + // Receipt: [monthly(free):50, lifetime:10] + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 60, + lock: { enabled: true, key: lockKey }, + }); + + // Verify state after check: total=90 + const afterCheck = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterCheck, + featureId: TestFeature.Messages, + remaining: 90, + }); + + // Upgrade free→pro. New monthly(pro)=80 added. Lifetime addon unaffected. + await autumnV2_1.attach({ + customer_id: customerId, + product_id: proProd.id, + }); + + // Verify state after upgrade: lifetime=90, monthly(pro)=80. Total=170. + const afterUpgrade = + await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterUpgrade, + featureId: TestFeature.Messages, + remaining: 170, + }); + + // Confirm override=63 → delta = 63-60 = +3. + // Continue deducting from lifetime (last bucket in receipt). lifetime=90→87. + // monthly(pro) not in receipt → stays at 80. + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 63, + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 167, + breakdown: { + [ResetInterval.Month]: { remaining: 77, usage: 3 }, + [ResetInterval.OneOff]: { remaining: 90, usage: 10 }, + }, + }); + + // Events newest-first: finalize(+3), check(60). + // The upgrade does not emit message events (free product, no billing). + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 3 }, { value: 60 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 167, + }); +}); diff --git a/server/tests/integration/balances/lock/check-with-lock-errors.test.ts b/server/tests/integration/balances/lock/check-with-lock-errors.test.ts new file mode 100644 index 000000000..2ee2cf0c4 --- /dev/null +++ b/server/tests/integration/balances/lock/check-with-lock-errors.test.ts @@ -0,0 +1,41 @@ +import { test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ───────────────────────────────────────────────────────────────────────────── +// ERR-1: lock on allocated feature → 400 +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("check-with-lock-errors ERR-1: lock not supported for allocated feature")}`, async () => { + const allocatedUsers = items.allocatedUsers({ includedUsage: 5 }); + const freeProd = products.base({ + id: "free", + items: [allocatedUsers], + }); + + const customerId = "lock-error-allocated-1"; + + const { autumnV2_1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + await expectAutumnError({ + func: async () => { + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Users, + required_balance: 1, + lock: { enabled: true, key: customerId }, + }); + }, + }); +}); diff --git a/server/tests/integration/balances/lock/check-with-lock-expiry.test.ts b/server/tests/integration/balances/lock/check-with-lock-expiry.test.ts new file mode 100644 index 000000000..42a334696 --- /dev/null +++ b/server/tests/integration/balances/lock/check-with-lock-expiry.test.ts @@ -0,0 +1,255 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { addSeconds } from "date-fns"; +import { redis } from "@/external/redis/initRedis"; +import { expireLock } from "@/internal/balances/finalizeLock/expireLock"; +import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey"; +import { timeout } from "@/utils/genUtils"; +import { getCustomerEvents } from "../utils/events/getCustomerEvents"; + +export const buildExpireLockPayload = ({ + ctx, + customerId, +}: { + ctx: TestContext; + customerId: string; +}) => { + return { + customerId, + orgId: ctx.org.id, + env: ctx.env, + lockKey: customerId, + hashedKey: buildLockReceiptKey({ + orgId: ctx.org.id, + env: ctx.env, + lockKey: customerId, + }), + }; +}; + +const makeFreeProd = () => { + const hourlyMessages = items.hourlyMessages({ includedUsage: 5 }); + const monthlyMessages = items.monthlyMessages({ includedUsage: 10 }); + return products.base({ + id: "free", + items: [hourlyMessages, monthlyMessages], + }); +}; + +test.concurrent(`${chalk.yellowBright("check-with-lock-expiry 1: /check with lock, expires at works (SQS)")}`, async () => { + const hourlyMessages = items.hourlyMessages({ includedUsage: 5 }); + const monthlyMessages = items.monthlyMessages({ includedUsage: 10 }); + const freeProd = products.base({ + id: "free", + items: [hourlyMessages, monthlyMessages], + }); + + const customerId = `check-lock-expiry-1`; + const { autumnV2_1, ctx } = await initScenario({ + customerId: customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ + ctx, + lockKey: customerId, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { + enabled: true, + key: customerId, + expires_at: addSeconds(new Date(), 5).getTime(), + }, + }); + + await timeout(60000); + + const customerAfter = + await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + remaining: 15, + }); +}); + +test.concurrent(`${chalk.yellowBright("check-with-lock-expiry 2: expires at undoes usage")}`, async () => { + const hourlyMessages = items.hourlyMessages({ includedUsage: 5 }); + const monthlyMessages = items.monthlyMessages({ includedUsage: 10 }); + const freeProd = products.base({ + id: "free", + items: [hourlyMessages, monthlyMessages], + }); + + const customerId = `check-lock-expiry-2`; + const { autumnV2_1, ctx } = await initScenario({ + customerId: customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ + ctx, + lockKey: customerId, + }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { + enabled: true, + key: customerId, + }, + }); + + // Run expire lock function + await expireLock({ + ctx, + payload: buildExpireLockPayload({ ctx, customerId }), + }); + + const customerAfter = + await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + remaining: 15, + }); + + await timeout(3000); + + // Grab events + const events = await getCustomerEvents({ + customerId, + }); + + expect(events).toHaveLength(2); + expect(events[0].value).toBe(-8); + expect(events[1].value).toBe(8); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// check-lock-expiry-3: expires_at > 1 day from now → HTTP 400 +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("check-lock-expiry-3: expires_at > 1 day from now is rejected")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "check-lock-expiry-3"; + + const { autumnV2_1 } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + const twoDaysFromNow = Date.now() + 2 * 24 * 60 * 60 * 1000; + + await expectAutumnError({ + func: async () => { + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 5, + lock: { + enabled: true, + key: customerId, + expires_at: twoDaysFromNow, + }, + }); + }, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// check-lock-expiry-4: no expires_at → TTL is ~1 day from now +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("check-lock-expiry-4: no expires_at sets TTL ~1 day from now")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "check-lock-expiry-4"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + const beforeCheck = Math.floor(Date.now() / 1000); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 5, + lock: { enabled: true, key: customerId }, + }); + + const lockReceiptKey = buildLockReceiptKey({ + orgId: ctx.org.id, + env: ctx.env, + lockKey: Bun.hash(customerId).toString(), + }); + + const expireAt = await redis.expiretime(lockReceiptKey); + const expectedTtl = beforeCheck + 24 * 60 * 60; + + // TTL should be within 5s of now + 1 day + expect(expireAt).toBeGreaterThanOrEqual(expectedTtl - 5); + expect(expireAt).toBeLessThanOrEqual(expectedTtl + 5); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// check-lock-expiry-5: expires_at set → TTL is expires_at + 1 hour +// Uses a unique ID per run to avoid duplicate EventBridge schedule errors +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("check-lock-expiry-5: expires_at set, TTL is expires_at + 1 hour")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = `check-lock-expiry-5-${Date.now()}`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + const expiresAt = Date.now() + 2 * 60 * 60 * 1000; + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 5, + lock: { enabled: true, key: customerId, expires_at: expiresAt }, + }); + + const lockReceiptKey = buildLockReceiptKey({ + orgId: ctx.org.id, + env: ctx.env, + lockKey: Bun.hash(customerId).toString(), + }); + + const expireAt = await redis.expiretime(lockReceiptKey); + const expectedTtl = Math.ceil(expiresAt / 1000) + 60 * 60; + + // TTL should be within 5s of expires_at + 1 hour + expect(expireAt).toBeGreaterThanOrEqual(expectedTtl - 5); + expect(expireAt).toBeLessThanOrEqual(expectedTtl + 5); +}); diff --git a/server/tests/integration/balances/lock/check-with-lock-postgres.test.ts b/server/tests/integration/balances/lock/check-with-lock-postgres.test.ts deleted file mode 100644 index 08fab12a2..000000000 --- a/server/tests/integration/balances/lock/check-with-lock-postgres.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { test } from "bun:test"; -import type { ApiCustomerV5 } from "@autumn/shared"; -import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { items } from "@tests/utils/fixtures/items.js"; -import { products } from "@tests/utils/fixtures/products.js"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; -import chalk from "chalk"; - -// ═══════════════════════════════════════════════════════════════════ -// CHECK: No feature attached -// ═══════════════════════════════════════════════════════════════════ - -test.concurrent(`${chalk.yellowBright("check-with-lock-postgres: /check with lock postgres")}`, async () => { - const hourlyMessages = items.hourlyMessages({ includedUsage: 5 }); - const monthlyMessages = items.monthlyMessages({ includedUsage: 10 }); - const freeProd = products.base({ - id: "free", - items: [hourlyMessages, monthlyMessages], - }); - - const { customerId, autumnV2, ctx } = await initScenario({ - customerId: "check-with-lock-postgres", - setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], - actions: [s.attach({ productId: freeProd.id })], - }); - - const lockKey = customerId; - - await deleteLock({ - ctx, - lockKey, - }); - - const firstResult = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - required_balance: 8, - lock: { - enabled: true, - key: lockKey, - }, - skip_cache: true, - }); - - // Release lock - await autumnV2.balances.finalize( - { - finalize_action: "confirm", - overwrite_value: 12, - lock_key: lockKey, - }, - { - skipCache: true, - }, - ); - - const customer = await autumnV2.customers.get(customerId); - - console.log("Message balance:", customer.balances[TestFeature.Messages]); -}); diff --git a/server/tests/integration/balances/lock/check-with-lock-race.test.ts b/server/tests/integration/balances/lock/check-with-lock-race.test.ts new file mode 100644 index 000000000..10b493dd0 --- /dev/null +++ b/server/tests/integration/balances/lock/check-with-lock-race.test.ts @@ -0,0 +1,300 @@ +import { expect, test } from "bun:test"; +import { type ApiCustomerV5, RecaseError } from "@autumn/shared"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { expireLock } from "@/internal/balances/finalizeLock/expireLock"; +import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey"; + +export const buildExpireLockPayload = ({ + ctx, + customerId, + lockKey, +}: { + ctx: TestContext; + customerId: string; + lockKey?: string; +}) => { + const key = lockKey ?? customerId; + return { + customerId, + orgId: ctx.org.id, + env: ctx.env, + lockKey: key, + hashedKey: buildLockReceiptKey({ + orgId: ctx.org.id, + env: ctx.env, + lockKey: Bun.hash(key).toString(), + }), + }; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared product setup +// ───────────────────────────────────────────────────────────────────────────── + +const makeFreeProd = () => { + const hourlyMessages = items.hourlyMessages({ includedUsage: 5 }); + const monthlyMessages = items.monthlyMessages({ includedUsage: 10 }); + return products.base({ + id: "free", + items: [hourlyMessages, monthlyMessages], + }); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// RC-1: Double expiry — concurrent expireLock x2 +// Expected: balance restored exactly once (unwind runs once, second is a no-op) +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("race RC-1: double expiry concurrent — balance restored exactly once")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = `race-rc1`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 3, + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + const payload = buildExpireLockPayload({ ctx, customerId }); + + // Fire both expiries concurrently — only one should unwind + await Promise.allSettled([ + expireLock({ ctx, payload }), + expireLock({ ctx, payload }), + ]); + + const customerAfter = + await autumnV2_1.customers.get(customerId); + + // Full 15 restored (8 unwound, not 16) + expectBalanceCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + remaining: 12, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// RC-2: Confirm then expire +// Expected: balance reflects confirm value (partial keep), expiry is a no-op +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("race RC-2: confirm then expire — expiry is no-op, balance reflects confirm")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-race-2"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + // Confirm keeping 4 usage (release 4 back) + await autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 4, + }); + + // Now expire — should be a no-op since lock is already confirmed + try { + await expireLock({ + ctx, + payload: buildExpireLockPayload({ ctx, customerId }), + }); + } catch (error) { + expect(error).toBeInstanceOf(RecaseError); + } + + const customerAfter = + await autumnV2_1.customers.get(customerId); + + // 15 total - 4 kept = 11 remaining + expectBalanceCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + remaining: 11, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// RC-3: Expire then confirm +// Expected: confirm returns gracefully (no throw), balance stays fully released +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("race RC-3: expire then confirm — confirm is graceful no-op")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-race-3"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + // Expire first — full release + await expireLock({ + ctx, + payload: buildExpireLockPayload({ ctx, customerId }), + }); + + // Confirm after expiry — should not throw, should be gracefully ignored + await expectAutumnError({ + func: () => + autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 4, + }), + }); + + const customerAfter = + await autumnV2_1.customers.get(customerId); + + // Full 15 restored by expiry, confirm was a no-op + expectBalanceCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + remaining: 15, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// RC-4: Confirm twice — duplicate confirm +// Expected: second confirm is idempotent, balance reflects first confirm value +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("race RC-4: confirm twice — idempotent, balance reflects first confirm")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-race-4"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + // Confirm twice concurrently — one should win and the other should be a graceful no-op + const results = await Promise.allSettled([ + autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 4, + }), + autumnV2_1.balances.finalize({ + lock_key: customerId, + action: "confirm", + override_value: 4, + }), + ]); + + expect(results.filter((r) => r.status === "fulfilled").length).toBe(1); + expect(results.filter((r) => r.status === "rejected").length).toBe(1); + + const customerAfter = + await autumnV2_1.customers.get(customerId); + + // 15 - 4 = 11, not 15 - 8 = 7 (double deduct) + expectBalanceCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + remaining: 11, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// RC-5: Receipt missing then expire +// Expected: expireLock handles missing receipt gracefully (no throw) +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("race RC-5: receipt evicted — expireLock handles missing receipt gracefully")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-race-5"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + }); + + // Simulate Redis eviction by deleting the receipt + await deleteLock({ ctx, lockKey: customerId }); + + try { + await expireLock({ + ctx, + payload: buildExpireLockPayload({ ctx, customerId }), + }); + } catch (error) { + expect(error).toBeInstanceOf(RecaseError); + } + + const customerAfter = + await autumnV2_1.customers.get(customerId); + + // Full 15 restored by expiry, confirm was a no-op + expectBalanceCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + remaining: 7, + }); +}); diff --git a/server/tests/integration/balances/lock/check-with-lock-rollovers.test.ts b/server/tests/integration/balances/lock/check-with-lock-rollovers.test.ts new file mode 100644 index 000000000..a630dc490 --- /dev/null +++ b/server/tests/integration/balances/lock/check-with-lock-rollovers.test.ts @@ -0,0 +1,240 @@ +import { test } from "bun:test"; +import { type ApiCustomerV5, RolloverExpiryDurationType } from "@autumn/shared"; +import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ───────────────────────────────────────────────────────────────────────────── +// Lock + Rollover tests +// +// Product: monthlyMessages(100) with rolloverConfig { max: 200, length: 1, duration: Month } +// Rollovers are created via s.resetFeature() which directly triggers the reset +// cron logic without needing a test clock. +// +// Deduction order: oldest rollover first, then newer rollovers, then main entitlement. +// LIFO unwind (on confirm with refund): main entitlement first, then newer rollovers, +// then older rollovers — reverse of deduction order. +// +// RO-1: Refund crosses main→rollover[1] boundary (two rollover buckets) +// RO-2: Refund crosses main→rollover boundary (single rollover bucket) +// RO-3: Additional deduction crosses rollover→main boundary +// ───────────────────────────────────────────────────────────────────────────── + +const makeFreeProd = () => + products.base({ + id: "free", + items: [ + items.monthlyMessagesWithRollover({ + includedUsage: 100, + rolloverConfig: { + max: 200, + length: 1, + duration: RolloverExpiryDurationType.Month, + }, + }), + ], + }); + +// ───────────────────────────────────────────────────────────────────────────── +// RO-1: Refund across multiple rollover boundaries +// +// Setup: +// reset (no usage) → rollover[0]=100, main resets to 100. Total=200. +// reset (no usage) → rollover[1]=100, main resets to 100. Total=300 (r[0]=100, r[1]=100, main=100). +// +// check lock=250: +// Deduction order: r[0]=100 (exhausted), r[1]=100 (exhausted), main=50. +// After check: r[0]=0, r[1]=0, main=50. Total=50. +// Receipt: [r[0]:100, r[1]:100, main:50] +// +// confirm override=180 → delta = 180-250 = -70 → refund 70: +// LIFO: unwind main=50 fully → main=100. Remaining=20. +// Unwind r[1]=20 (of 100 in receipt) → r[1]=20. Remaining=0. r[0] stays 0. +// Final: r[0]=0, r[1]=20, main=100. Total=120. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-rollover RO-1: lock across two rollovers + main, confirm with refund crossing main→rollover[1] boundary")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-ro-1"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [ + s.attach({ productId: freeProd.id }), + // Cycle 1: no usage, reset → rollover[0]=100 + s.resetFeature({ featureId: TestFeature.Messages }), + // Cycle 2: no usage, reset → rollover[1]=100 + s.resetFeature({ featureId: TestFeature.Messages }), + ], + }); + + await deleteLock({ ctx, lockKey }); + + // Verify state after setup: r[0]=100, r[1]=100, main=100. Total=300. + const afterSetup = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterSetup, + featureId: TestFeature.Messages, + remaining: 300, + rollovers: [{ balance: 100 }, { balance: 100 }], + }); + + // check lock=250: exhausts r[0]=100, r[1]=100, deducts 50 from main. + // Receipt: [r[0]:100, r[1]:100, main:50] + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 250, + lock: { enabled: true, key: lockKey }, + }); + + const afterCheck = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterCheck, + featureId: TestFeature.Messages, + remaining: 50, + rollovers: [{ balance: 0 }, { balance: 0 }], + }); + + // confirm override=180 → delta = -70 → refund 70. + // LIFO: unwind main=50 fully → main=100. Remaining=20. + // Unwind r[1]=20 → r[1]=20. r[0] stays 0. + // Final: r[0]=0, r[1]=20, main=100. Total=120. + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 80, + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 220, + rollovers: [{ balance: 20 }, { balance: 100 }], + }); + + // Events newest-first: finalize(-70), check(250). No track events (resets don't emit). + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -170 }, { value: 250 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 220, + rollovers: [{ balance: 20 }, { balance: 100 }], + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// RO-3: Additional deduction past rollover boundary +// +// Setup: +// reset (no usage) → rollover[0]=100, main resets to 100. Total=200. +// +// check lock=30: +// Deduction order: r[0]=30. After: r[0]=70, main=100. Total=170. +// Receipt: [r[0]:30] +// +// confirm override=150 → delta = 150-30 = +120 → additional deduction of 120. +// Full unwind of receipt: r[0] restored to 100. Then re-deduct 150 total. +// Re-deduct 150: r[0]=100→0, main=100→50. +// Final: r[0]=0, main=50. Total=50. +// +// Event value = override_value - locked_value = 150-30 = 120. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-rollover RO-3: lock within rollover, confirm with extra deduction past rollover→main boundary")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-ro-3"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [ + s.attach({ productId: freeProd.id }), + // No usage before reset → full rollover of 100 + s.resetFeature({ featureId: TestFeature.Messages }), + ], + }); + + await deleteLock({ ctx, lockKey }); + + // Verify state after setup: r[0]=100, main=100. Total=200. + const afterSetup = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterSetup, + featureId: TestFeature.Messages, + remaining: 200, + rollovers: [{ balance: 100 }], + }); + + // check lock=30: deducts 30 from r[0]. + // Receipt: [r[0]:30] + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 30, + lock: { enabled: true, key: lockKey }, + }); + + const afterCheck = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterCheck, + featureId: TestFeature.Messages, + remaining: 170, + rollovers: [{ balance: 70 }], + }); + + // confirm override=150 → delta = +120 → additional deduction of 120. + // Fully unwind receipt (restore 30 to r[0] → r[0]=100), then re-deduct 150. + // Re-deduct 150: r[0]=100→0, main=100→50. + // Final: r[0]=0, main=50. Total=50. + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 150, + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 50, + rollovers: [{ balance: 0 }], + }); + + // Events newest-first: finalize(delta=120), check(30). No track events. + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 120 }, { value: 30 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 50, + rollovers: [{ balance: 0 }], + }); +}); diff --git a/server/tests/integration/balances/lock/entities/check-lock-entity-product.test.ts b/server/tests/integration/balances/lock/entities/check-lock-entity-product.test.ts new file mode 100644 index 000000000..2aad006de --- /dev/null +++ b/server/tests/integration/balances/lock/entities/check-lock-entity-product.test.ts @@ -0,0 +1,694 @@ +import { test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ───────────────────────────────────────────────────────────────────────────── +// Setup A — Mixed (customer product + entity product attached to each entity) +// +// customerProd: monthlyMessages(100) → customer-level bucket +// entityProd: monthlyMessages(50) → per-entity bucket (one product, attached twice) +// +// Initial state: +// customer total = 200 (100 + 50 + 50) +// ent-1 view = 150 (50 own + 100 customer) +// ent-2 view = 150 (50 own + 100 customer) +// +// Customer-level deduction order: customer bucket → ent-1 bucket → ent-2 bucket +// Entity-level deduction order: entity own bucket → customer bucket (never other entity) +// +// Setup B — Entity-only (no customer product; same entityProd attached to each entity) +// +// entityProd: monthlyMessages(50) +// +// Initial state: +// customer total = 100 (50 + 50) +// ent-1 view = 50 (own only) +// ent-2 view = 50 (own only) +// +// Customer-level deduction order: ent-1 bucket → ent-2 bucket (alphabetical; no customer bucket) +// ───────────────────────────────────────────────────────────────────────────── + +const makeCustomerProd = () => + products.base({ + id: "customer-prod", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + +const makeEntityProd = () => + products.base({ + id: "entity-prod", + items: [items.monthlyMessages({ includedUsage: 50 })], + }); + +// ───────────────────────────────────────────────────────────────────────────── +// EQ-1 [Setup A]: entity-level lock=30 on ent-1, confirm=10 (partial refund) +// Check: ent-1 own 50→20. Confirm delta=10-30=-20 → restore 20 to ent-1 (→40). +// Final: customer=100, ent-1=40, ent-2=50. total=190, ent-1 view=140, ent-2 view=150. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-entity-prod EQ-1: [mixed] entity lock=30 on ent-1 confirm=10 — partial refund")}`, async () => { + const customerProd = makeCustomerProd(); + const entityProd = makeEntityProd(); + const customerId = "lock-eq-1"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProd, entityProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: customerProd.id }), + s.attach({ productId: entityProd.id, entityIndex: 0 }), + s.attach({ productId: entityProd.id, entityIndex: 1 }), + ], + }); + + await deleteLock({ ctx, lockKey }); + + await autumnV2_1.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: 30, + lock: { enabled: true, key: lockKey }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 10, + }); + + // delta = 10 - 30 = -20 → restore 20 to ent-1 + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 190, + }); + + const ent1 = await autumnV2_1.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: ent1, + featureId: TestFeature.Messages, + remaining: 140, + }); + + const ent2 = await autumnV2_1.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: ent2, + featureId: TestFeature.Messages, + remaining: 150, + }); + + // Events newest-first: finalize(-20), check(30) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -20 }, { value: 30 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 190, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EQ-2 [Setup A]: entity-level lock=30 on ent-1, confirm=80 — spills into customer bucket +// Check: ent-1 own 50→20. Confirm delta=80-30=+50: +// deduct 20 more from ent-1 (→0), then 30 from customer (→70). +// ent-2 NEVER touched (entity isolation). +// Final: customer=70, ent-1=0, ent-2=50. total=120, ent-1 view=70, ent-2 view=120. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-entity-prod EQ-2: [mixed] entity lock=30 on ent-1 confirm=80 — spills into customer bucket, ent-2 untouched")}`, async () => { + const customerProd = makeCustomerProd(); + const entityProd = makeEntityProd(); + const customerId = "lock-eq-2"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProd, entityProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: customerProd.id }), + s.attach({ productId: entityProd.id, entityIndex: 0 }), + s.attach({ productId: entityProd.id, entityIndex: 1 }), + ], + }); + + await deleteLock({ ctx, lockKey }); + + await autumnV2_1.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: 30, + lock: { enabled: true, key: lockKey }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 80, + }); + + // delta = 80 - 30 = +50 → exhaust ent-1 own (20→0), spill 30 into customer (100→70) + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 120, + }); + + const ent1 = await autumnV2_1.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: ent1, + featureId: TestFeature.Messages, + remaining: 70, + }); + + const ent2 = await autumnV2_1.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: ent2, + featureId: TestFeature.Messages, + remaining: 120, + }); + + // Events newest-first: finalize(+50), check(30) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 50 }, { value: 30 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 120, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EQ-3 [Setup A]: customer-level lock=120, confirm=60 — LIFO unwind across entity buckets +// Check: customer 100→0, ent-1 50→30. Receipt: [customer:100, ent-1:20]. total=80. +// Confirm delta=60-120=-60 → LIFO: restore 20 to ent-1 (→50), restore 40 to customer (→40). +// Final: customer=40, ent-1=50, ent-2=50. total=140, ent-1 view=90, ent-2 view=90. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-entity-prod EQ-3: [mixed] customer lock=120 confirm=60 — LIFO unwind across entity buckets")}`, async () => { + const customerProd = makeCustomerProd(); + const entityProd = makeEntityProd(); + const customerId = "lock-eq-3"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProd, entityProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: customerProd.id }), + s.attach({ productId: entityProd.id, entityIndex: 0 }), + s.attach({ productId: entityProd.id, entityIndex: 1 }), + ], + }); + + await deleteLock({ ctx, lockKey }); + + // No entity_id → customer-level lock, draws customer then ent-1 + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 120, + lock: { enabled: true, key: lockKey }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 60, + }); + + // delta = 60 - 120 = -60 → LIFO: restore 20 to ent-1 (50→50), restore 40 to customer (0→40) + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 140, + }); + + const ent1 = await autumnV2_1.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: ent1, + featureId: TestFeature.Messages, + remaining: 90, + }); + + const ent2 = await autumnV2_1.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: ent2, + featureId: TestFeature.Messages, + remaining: 90, + }); + + // Events newest-first: finalize(-60), check(120) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -60 }, { value: 120 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 140, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EQ-4 [Setup A]: customer-level lock=120, confirm=160 — extra deduction reaches ent-2 +// Check: customer 100→0, ent-1 50→30. Receipt: [customer:100, ent-1:20]. total=80. +// Confirm delta=160-120=+40 → deduct 30 from ent-1 (→0), 10 from ent-2 (→40). +// Final: customer=0, ent-1=0, ent-2=40. total=40, ent-1 view=0, ent-2 view=40. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-entity-prod EQ-4: [mixed] customer lock=120 confirm=160 — extra deduction reaches ent-2")}`, async () => { + const customerProd = makeCustomerProd(); + const entityProd = makeEntityProd(); + const customerId = "lock-eq-4"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProd, entityProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: customerProd.id }), + s.attach({ productId: entityProd.id, entityIndex: 0 }), + s.attach({ productId: entityProd.id, entityIndex: 1 }), + ], + }); + + await deleteLock({ ctx, lockKey }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 120, + lock: { enabled: true, key: lockKey }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 160, + }); + + // delta = 160 - 120 = +40 → exhaust ent-1 remaining 30 (→0), then 10 from ent-2 (→40) + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 40, + }); + + const ent1 = await autumnV2_1.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: ent1, + featureId: TestFeature.Messages, + remaining: 0, + }); + + const ent2 = await autumnV2_1.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: ent2, + featureId: TestFeature.Messages, + remaining: 40, + }); + + // Events newest-first: finalize(+40), check(120) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 40 }, { value: 120 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 40, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EQ-5 [Setup B — entity-only]: customer-level lock=80 crosses ent-1→ent-2 boundary, +// confirm=30 — LIFO unwind back across the boundary. +// No customer product; only entity products. +// +// Initial: ent-1=50, ent-2=50, customer total=100. +// Check (no entity_id): deduction order ent-1→ent-2. +// ent-1: 50→0, ent-2: 50→20. Receipt: [ent-1:50, ent-2:30]. total=20. +// Confirm delta=30-80=-50 → LIFO: restore 30 to ent-2 (→50), restore 20 to ent-1 (→20). +// Final: ent-1=20, ent-2=50. total=70, ent-1 view=20, ent-2 view=50. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-entity-prod EQ-5: [entity-only] customer lock=80 crosses ent-1→ent-2 boundary, confirm=30 — LIFO unwind crosses back")}`, async () => { + const entityProd = makeEntityProd(); + const customerId = "lock-eq-5"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [entityProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + // No customer-level product — entities only + s.attach({ productId: entityProd.id, entityIndex: 0 }), + s.attach({ productId: entityProd.id, entityIndex: 1 }), + ], + }); + + await deleteLock({ ctx, lockKey }); + + // Customer-level lock, no customer product — draws ent-1 then ent-2 + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 80, + lock: { enabled: true, key: lockKey }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 30, + }); + + // delta = 30 - 80 = -50 → LIFO: restore 30 to ent-2 (20→50), restore 20 to ent-1 (0→20) + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 70, + }); + + const ent1 = await autumnV2_1.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: ent1, + featureId: TestFeature.Messages, + remaining: 20, + }); + + const ent2 = await autumnV2_1.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: ent2, + featureId: TestFeature.Messages, + remaining: 50, + }); + + // Events newest-first: finalize(-50), check(80) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -50 }, { value: 80 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 70, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EQ-6 [Setup B — entity-only]: customer-level lock=80, confirm=100 — extra deduction +// deeper into ent-2 after the boundary was already crossed during check. +// +// Check: ent-1 50→0, ent-2 50→20. Receipt: [ent-1:50, ent-2:30]. total=20. +// Confirm delta=100-80=+20 → deduct 20 more from ent-2 (20→0). +// Final: ent-1=0, ent-2=0. total=0. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-entity-prod EQ-6: [entity-only] customer lock=80, confirm=100 — extra deduction goes deeper into ent-2")}`, async () => { + const entityProd = makeEntityProd(); + const customerId = "lock-eq-6"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [entityProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: entityProd.id, entityIndex: 0 }), + s.attach({ productId: entityProd.id, entityIndex: 1 }), + ], + }); + + await deleteLock({ ctx, lockKey }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 80, + lock: { enabled: true, key: lockKey }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 100, + }); + + // delta = 100 - 80 = +20 → deduct 20 from ent-2 (20→0) + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 0, + }); + + const ent1 = await autumnV2_1.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: ent1, + featureId: TestFeature.Messages, + remaining: 0, + }); + + const ent2 = await autumnV2_1.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: ent2, + featureId: TestFeature.Messages, + remaining: 0, + }); + + // Events newest-first: finalize(+20), check(80) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 20 }, { value: 80 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 0, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EQ-7 [Setup A]: two concurrent entity locks (ent-1 lock A, ent-2 lock B) — both confirmed. +// Lock A on ent-1: lock=30 → ent-1 own 50→20. +// Lock B on ent-2: lock=20 → ent-2 own 50→30. +// After both checks: total=150. +// Confirm A override=15: delta=15-30=-15 → restore 15 to ent-1 (→35). total→165. +// Confirm B override=25: delta=25-20=+5 → deduct 5 from ent-2 (→25). total→160. +// Final: customer=100, ent-1=35, ent-2=25. total=160, ent-1 view=135, ent-2 view=125. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-entity-prod EQ-7: [mixed] two concurrent entity locks (ent-1 + ent-2) — independent receipts, both confirmed")}`, async () => { + const customerProd = makeCustomerProd(); + const entityProd = makeEntityProd(); + const customerId = "lock-eq-7"; + const lockKeyA = `${customerId}-lock-a`; + const lockKeyB = `${customerId}-lock-b`; + + const { autumnV2_1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProd, entityProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: customerProd.id }), + s.attach({ productId: entityProd.id, entityIndex: 0 }), + s.attach({ productId: entityProd.id, entityIndex: 1 }), + ], + }); + + await Promise.all([ + deleteLock({ ctx, lockKey: lockKeyA }), + deleteLock({ ctx, lockKey: lockKeyB }), + ]); + + // Fire both locks concurrently + await Promise.all([ + autumnV2_1.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: 30, + lock: { enabled: true, key: lockKeyA }, + }), + autumnV2_1.check({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + required_balance: 20, + lock: { enabled: true, key: lockKeyB }, + }), + ]); + + // Confirm both concurrently with different override values + await Promise.all([ + autumnV2_1.balances.finalize({ + lock_key: lockKeyA, + action: "confirm", + override_value: 15, + }), + autumnV2_1.balances.finalize({ + lock_key: lockKeyB, + action: "confirm", + override_value: 25, + }), + ]); + + // Confirm A delta=-15 → restore 15 to ent-1 (20→35) + // Confirm B delta=+5 → deduct 5 from ent-2 (30→25) + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 160, + }); + + const ent1 = await autumnV2_1.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: ent1, + featureId: TestFeature.Messages, + remaining: 135, + }); + + const ent2 = await autumnV2_1.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: ent2, + featureId: TestFeature.Messages, + remaining: 125, + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 160, + }); +}); diff --git a/server/tests/integration/balances/lock/entities/check-lock-per-entity.test.ts b/server/tests/integration/balances/lock/entities/check-lock-per-entity.test.ts new file mode 100644 index 000000000..d04b108b2 --- /dev/null +++ b/server/tests/integration/balances/lock/entities/check-lock-per-entity.test.ts @@ -0,0 +1,641 @@ +import { test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectLockReceiptDeleted } from "@tests/integration/balances/utils/lockUtils/expectLockReceiptDeleted.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ───────────────────────────────────────────────────────────────────────────── +// Setup: customer product with two message items: +// - monthlyMessages(100) → customer-level bucket +// - monthlyMessages(50, entityFeatureId: Users) → per-entity bucket (50 each) +// Two entities: ent-1, ent-2 +// +// Initial state: +// customer total = 100 + 50 + 50 = 200 +// ent-1 view = 50 (own) + 100 (customer) = 150 +// ent-2 view = 50 (own) + 100 (customer) = 150 +// +// Deduction order: +// entity-level track (ent-1): ent-1 bucket → customer bucket (never ent-2) +// customer-level track: customer bucket → ent-1 bucket → ent-2 bucket (alphabetical) +// ───────────────────────────────────────────────────────────────────────────── + +const makeProd = () => + products.base({ + id: "free", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyMessages({ + includedUsage: 50, + entityFeatureId: TestFeature.Users, + }), + ], + }); + +// ───────────────────────────────────────────────────────────────────────────── +// EP-2: entity-level lock on ent-1, confirm=10 (partial refund) +// Lock=30 deducts from ent-1 bucket (→20). Confirm=10 → delta=-20 → +// restore 20 to ent-1 (→40). ent-2 untouched. +// Final: customer=100, ent-1 own=40, ent-2 own=50. +// Customer total=190, ent-1 view=140, ent-2 view=150. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-entity EP-2: entity-level lock=30 confirm=10 — partial refund to ent-1, ent-2 untouched")}`, async () => { + const freeProd = makeProd(); + const customerId = "lock-entity-2"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey }); + + // Lock at entity level (ent-1) + await autumnV2_1.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: 30, + lock: { enabled: true, key: lockKey }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 10, + }); + + // delta = 10 - 30 = -20 → 20 restored to ent-1 + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 190, + }); + + const ent1 = await autumnV2_1.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: ent1, + featureId: TestFeature.Messages, + remaining: 140, + }); + + const ent2 = await autumnV2_1.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: ent2, + featureId: TestFeature.Messages, + remaining: 150, + }); + + // Events scoped to ent-1: finalize(-20), check(30) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -20 }, { value: 30 }], + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EP-3: entity-level lock on ent-1, confirm=80 (confirm > lock, spills into customer bucket) +// Lock=30 deducts from ent-1 bucket (→20). Confirm=80 → delta=+50: +// deduct 20 more from ent-1 bucket (→0), then 30 from customer bucket (→70). +// ent-2 is NEVER touched (entity isolation). +// Final: customer=70, ent-1 own=0, ent-2 own=50. +// Customer total=120, ent-1 view=70, ent-2 view=120. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-entity EP-3: entity-level lock=30 confirm=80 — spills into customer bucket, ent-2 untouched")}`, async () => { + const freeProd = makeProd(); + const customerId = "lock-entity-3"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey }); + + await autumnV2_1.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: 30, + lock: { enabled: true, key: lockKey }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 80, + }); + + // Lock deducted 30 from ent-1 (→20). Confirm delta=+50: exhaust ent-1 (20→0), then 30 from customer (→70). + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 120, + }); + + const ent1 = await autumnV2_1.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: ent1, + featureId: TestFeature.Messages, + remaining: 70, + }); + + const ent2 = await autumnV2_1.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: ent2, + featureId: TestFeature.Messages, + remaining: 120, + }); + + // Events: finalize(+50), check(30) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 50 }, { value: 30 }], + }); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 120, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EP-4: entity-level lock on ent-1, release — full restore +// Lock=40 deducts 40 from ent-1 bucket (→10). Release → full unwind. +// Final: customer=100, ent-1 own=50, ent-2 own=50. Total=200. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-entity EP-4: entity-level lock=40 release — ent-1 fully restored, ent-2 untouched")}`, async () => { + const freeProd = makeProd(); + const customerId = "lock-entity-4"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey }); + + await autumnV2_1.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: 40, + lock: { enabled: true, key: lockKey }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "release", + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 200, + }); + + const ent1 = await autumnV2_1.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: ent1, + featureId: TestFeature.Messages, + remaining: 150, + }); + + const ent2 = await autumnV2_1.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: ent2, + featureId: TestFeature.Messages, + remaining: 150, + }); + + // release: delta=0-40=-40. Events: finalize(-40), check(40) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -40 }, { value: 40 }], + }); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 200, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EP-5: customer-level lock=120, confirm=60 — unwinds across entity buckets LIFO +// Lock=120 (no entity_id): deducts 100 from customer bucket (→0), then 20 from ent-1 (→30). +// Receipt (in order): [customer: 100, ent-1: 20]. +// Confirm=60 → delta=-60 → unwind LIFO: restore 20 to ent-1 (→50), restore 40 to customer (→40). +// Final: customer=40, ent-1 own=50, ent-2 own=50. Customer total=140, ent-1 view=90, ent-2 view=90. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-entity EP-5: entity-level across lock=12 confirm=8 — LIFO unwind across entity buckets")}`, async () => { + const customerId = "lock-entity-5"; + const lockKey = `${customerId}-lock`; + + const freeProd = products.base({ + id: "pro", + items: [ + items.monthlyMessages({ + includedUsage: 10, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const { autumnV2_1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey }); + + // Customer-level lock (no entity_id) + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 14, + lock: { enabled: true, key: lockKey }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 8, + }); + + // delta=-60: restore 20 to ent-1 (→50), restore 40 to customer (→40) + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 12, + }); + + const ent1 = await autumnV2_1.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: ent1, + featureId: TestFeature.Messages, + remaining: 2, + }); + + const ent2 = await autumnV2_1.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: ent2, + featureId: TestFeature.Messages, + remaining: 10, + }); + + // Events: finalize(-60), check(120) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -6 }, { value: 14 }], + }); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 12, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EP-6: customer-level lock=120, confirm=160 — extra deduction continues into ent-2 +// Lock=120: customer=0, ent-1=30. Receipt: [customer: 100, ent-1: 20]. +// Confirm=160 → delta=+40: deduct 30 more from ent-1 (→0), then 10 from ent-2 (→40). +// Final: customer=0, ent-1 own=0, ent-2 own=40. Customer total=40, ent-1 view=0, ent-2 view=40. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-entity EP-6: customer-level lock=120 confirm=160 — extra deduction reaches ent-2")}`, async () => { + const freeProd = makeProd(); + const customerId = "lock-entity-6"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 120, + lock: { enabled: true, key: lockKey }, + }); + + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + override_value: 160, + }); + + // delta=+40: exhaust ent-1 remaining 30 (→0), then 10 from ent-2 (→40) + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 40, + }); + + const ent1 = await autumnV2_1.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: ent1, + featureId: TestFeature.Messages, + remaining: 0, + }); + + const ent2 = await autumnV2_1.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: ent2, + featureId: TestFeature.Messages, + remaining: 40, + }); + + // Events: finalize(+40), check(120) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 40 }, { value: 120 }], + }); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 40, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EP-7: entity-level lock on ent-1 + concurrent customer-level track (no lock) +// Lock on ent-1: lock=30 → ent-1 own=20. While lock is held, plain track(10) at +// customer level fires — deducts 10 from customer bucket (→90). Confirm with no +// override_value → early exit (finalValue=lockValue). Receipt deleted. +// Final: customer=90, ent-1 own=20, ent-2 own=50. Total=160, ent-1 view=110, ent-2 view=140. +// Only 2 events: check(30) and the unrelated track(10). No finalize event (early exit). +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-entity EP-7: ent-1 lock held while customer-level track fires — both resolve independently")}`, async () => { + const freeProd = makeProd(); + const customerId = "lock-entity-7"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey }); + + // Lock ent-1 (deducts 30 from ent-1 bucket) + await autumnV2_1.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: 30, + lock: { enabled: true, key: lockKey }, + }); + + // While lock is held, fire an independent customer-level track + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + // Confirm with no override_value → early exit (finalValue === lockValue=30) + await autumnV2_1.balances.finalize({ + lock_key: lockKey, + action: "confirm", + }); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 160, + }); + + const ent1 = await autumnV2_1.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: ent1, + featureId: TestFeature.Messages, + remaining: 110, + }); + + const ent2 = await autumnV2_1.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: ent2, + featureId: TestFeature.Messages, + remaining: 140, + }); + + // No finalize event (early exit). Events newest-first: check(30), track(10) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 10 }, { value: 30 }], + }); + + // Receipt must be cleaned up after early-exit confirm + await expectLockReceiptDeleted({ ctx, lockKey }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EP-8: two concurrent entity locks (ent-1 lock A, ent-2 lock B) — independent receipts +// Lock A on ent-1: lock=30 → ent-1 own=20. +// Lock B on ent-2: lock=20 → ent-2 own=30. +// Confirm A (override=15): delta=-15 → restore 15 to ent-1 (→35). +// Confirm B (override=25): delta=+5 → deduct 5 more from ent-2 (→25). +// Final: customer=100, ent-1 own=35, ent-2 own=25. +// Customer total=160, ent-1 view=135, ent-2 view=125. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-entity EP-8: two concurrent entity locks (ent-1 + ent-2) — independent receipts, both confirmed")}`, async () => { + const freeProd = makeProd(); + const customerId = "lock-entity-8"; + const lockKeyA = `${customerId}-lock-a`; + const lockKeyB = `${customerId}-lock-b`; + + const { autumnV2_1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + await Promise.all([ + deleteLock({ ctx, lockKey: lockKeyA }), + deleteLock({ ctx, lockKey: lockKeyB }), + ]); + + // Fire both locks concurrently + await Promise.all([ + autumnV2_1.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: 30, + lock: { enabled: true, key: lockKeyA }, + }), + autumnV2_1.check({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + required_balance: 20, + lock: { enabled: true, key: lockKeyB }, + }), + ]); + + // Confirm both concurrently with different override values + await Promise.all([ + autumnV2_1.balances.finalize({ + lock_key: lockKeyA, + action: "confirm", + override_value: 15, + }), + autumnV2_1.balances.finalize({ + lock_key: lockKeyB, + action: "confirm", + override_value: 25, + }), + ]); + + // Lock A confirm: delta=15-30=-15 → restore 15 to ent-1 (→35) + // Lock B confirm: delta=25-20=+5 → deduct 5 more from ent-2 (→25) + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 160, + }); + + const ent1 = await autumnV2_1.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: ent1, + featureId: TestFeature.Messages, + remaining: 135, + }); + + const ent2 = await autumnV2_1.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: ent2, + featureId: TestFeature.Messages, + remaining: 125, + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 160, + }); +}); diff --git a/server/tests/integration/balances/lock/postgres/check-with-lock-postgres-edge-cases.test.ts b/server/tests/integration/balances/lock/postgres/check-with-lock-postgres-edge-cases.test.ts new file mode 100644 index 000000000..2c6ec4dee --- /dev/null +++ b/server/tests/integration/balances/lock/postgres/check-with-lock-postgres-edge-cases.test.ts @@ -0,0 +1,399 @@ +import { test } from "bun:test"; +import { type ApiCustomerV5, ResetInterval } from "@autumn/shared"; +import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ───────────────────────────────────────────────────────────────────────────── +// Edge case: lock spans multiple entitlement types, then the product is upgraded +// mid-flight (lock held → confirm after upgrade). +// +// Setup: +// addonProd: lifetimeMessages(100) — never resets, add-on +// freeProd: monthlyMessages(50) — resets monthly, customer product +// proProd: monthlyMessages(80) — resets monthly, upgrade target +// +// Initial state: lifetime=100, monthly(free)=50. Total=150. +// +// Check lock=60 (no entity_id): +// Deduction order: monthly(free) → lifetime (monthly exhausted first). +// monthly: 50→0, lifetime: 100→90. +// Receipt records: [monthly:50, lifetime:10]. Total after check=90. +// +// Upgrade free→pro: +// Old monthly(free) entitlement is replaced by monthly(pro)=80. +// Lifetime addon bucket is unaffected (persists across upgrade). +// State after upgrade: lifetime=90, monthly(pro)=80. Total=170. +// +// Key: the lock receipt still references the OLD monthly entitlement ID (now gone) +// and the lifetime entitlement ID. On finalize: +// +// EC-1 (confirm override=57, delta=-3 → LIFO refund of 3): +// LIFO unwinds last bucket first = lifetime. Restore 3 → lifetime=93. +// monthly(pro) is untouched (not in receipt). Total=173. +// +// EC-2 (confirm override=63, delta=+3 → additional deduction of 3): +// Continue deducting from lifetime (last bucket). lifetime=90→87. +// monthly(pro) is untouched (not in receipt). Total=167. +// ───────────────────────────────────────────────────────────────────────────── + +const makeAddonProd = () => + products.base({ + id: "addon", + isAddOn: true, + items: [items.lifetimeMessages({ includedUsage: 100 })], + }); + +const makeFreeProd = () => + products.base({ + id: "free", + items: [items.monthlyMessages({ includedUsage: 50 })], + }); + +const makeProProd = () => + products.base({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 80 })], + }); + +// ───────────────────────────────────────────────────────────────────────────── +// PG-EC-1: lock=60 (crosses monthly→lifetime), upgrade free→pro, confirm=57 (delta=-3) +// Postgres path (skip_cache=true forces DB deduction). +// LIFO unwind restores 3 to lifetime. monthly(pro) stays at full 80. +// Final: lifetime=93, monthly(pro)=80. Total=173. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-pg-edge PG-EC-1: lock crosses monthly→lifetime, upgrade mid-flight, confirm with refund — postgres path, monthly(pro) untouched, lifetime refunded")}`, async () => { + const addonProd = makeAddonProd(); + const freeProd = makeFreeProd(); + const proProd = makeProProd(); + const customerId = "lock-pg-edge-1"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [addonProd, freeProd, proProd] }), + ], + actions: [ + // Attach addon first (lifetime bucket), then free (monthly bucket) + s.attach({ productId: addonProd.id }), + s.attach({ productId: freeProd.id }), + ], + }); + + await deleteLock({ ctx, lockKey }); + + // Check lock=60: exhausts monthly(free)=50, then deducts 10 from lifetime. + // Receipt: [monthly(free):50, lifetime:10] + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 60, + lock: { enabled: true, key: lockKey }, + skip_cache: true, + }); + + // Verify state after check: total=90 (lifetime=90, monthly=0) + const afterCheck = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterCheck, + featureId: TestFeature.Messages, + remaining: 90, + }); + + // Upgrade: free→pro. New monthly entitlement (80) replaces old monthly (0). + // Lifetime addon is unaffected. Lock receipt still references old monthly ID. + await autumnV2_1.attach({ + customer_id: customerId, + product_id: proProd.id, + }); + + // Verify state after upgrade: lifetime=90, monthly(pro)=80. Total=170. + const afterUpgrade = + await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterUpgrade, + featureId: TestFeature.Messages, + remaining: 170, + }); + + // Confirm override=57 → delta = 57-60 = -3. + // LIFO: restore 3 to lifetime (last bucket touched). lifetime=90→93. + // monthly(pro) not in receipt → stays at 80. + await autumnV2_1.balances.finalize( + { + lock_key: lockKey, + action: "confirm", + override_value: 57, + }, + { + skipCache: true, + }, + ); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 173, + breakdown: { + [ResetInterval.Month]: { remaining: 80, usage: 0 }, + [ResetInterval.OneOff]: { remaining: 93, usage: 7 }, + }, + }); + + // Events newest-first: finalize(-3), check(60). + // The upgrade does not emit message events (free product, no billing). + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -3 }, { value: 60 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 173, + breakdown: { + [ResetInterval.Month]: { remaining: 80, usage: 0 }, + [ResetInterval.OneOff]: { remaining: 93, usage: 7 }, + }, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// PG-EC-2: lock=60 (crosses monthly→lifetime), upgrade free→pro, confirm=63 (delta=+3) +// Postgres path (skip_cache=true forces DB deduction). +// Extra +3 deduction runs against current live entitlements in normal order: +// monthly(pro) first → monthly(pro)=80→77. lifetime stays at 90 (unchanged). +// Final: lifetime=90, monthly(pro)=77. Total=167. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-pg-edge PG-EC-2: lock crosses monthly→lifetime, upgrade mid-flight, confirm with extra deduction — postgres path, monthly deducted")}`, async () => { + const addonProd = makeAddonProd(); + const freeProd = makeFreeProd(); + const proProd = makeProProd(); + const customerId = "lock-pg-edge-2"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [addonProd, freeProd, proProd] }), + ], + actions: [ + s.attach({ productId: addonProd.id }), + s.attach({ productId: freeProd.id }), + ], + }); + + await deleteLock({ ctx, lockKey }); + + // Check lock=60: exhausts monthly(free)=50, deducts 10 from lifetime. + // Receipt: [monthly(free):50, lifetime:10] + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 60, + lock: { enabled: true, key: lockKey }, + skip_cache: true, + }); + + // Verify state after check: total=90 + const afterCheck = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterCheck, + featureId: TestFeature.Messages, + remaining: 90, + }); + + // Upgrade free→pro. New monthly(pro)=80 added. Lifetime addon unaffected. + await autumnV2_1.attach({ + customer_id: customerId, + product_id: proProd.id, + }); + + // Verify state after upgrade: lifetime=90, monthly(pro)=80. Total=170. + const afterUpgrade = + await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterUpgrade, + featureId: TestFeature.Messages, + remaining: 170, + }); + + // Confirm override=63 → delta = 63-60 = +3. + // Continue deducting from lifetime (last bucket in receipt). lifetime=90→87. + // monthly(pro) not in receipt → stays at 80. + await autumnV2_1.balances.finalize( + { + lock_key: lockKey, + action: "confirm", + override_value: 63, + }, + { + skipCache: true, + }, + ); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 167, + breakdown: { + [ResetInterval.Month]: { remaining: 77, usage: 3 }, + [ResetInterval.OneOff]: { remaining: 90, usage: 10 }, + }, + }); + + // Events newest-first: finalize(+3), check(60). + // The upgrade does not emit message events (free product, no billing). + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 3 }, { value: 60 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 167, + breakdown: { + [ResetInterval.Month]: { remaining: 77, usage: 3 }, + [ResetInterval.OneOff]: { remaining: 90, usage: 10 }, + }, + }); +}); +// ───────────────────────────────────────────────────────────────────────────── +// EC-3: lock on free-only product, upgrade to pro, track on pro, confirm with +// refund — skipped unwind redirects refund onto current (pro) entitlement. +// +// Setup: freeProd monthlyMessages(50) only (no lifetime addon). +// proProd monthlyMessages(80). +// +// check lock=40 on free: monthly(free)=50→10. Receipt: [monthly(free):40]. +// Upgrade free→pro: monthly(free) entitlement replaced by monthly(pro)=80. +// Receipt still references old monthly(free) ID (now gone). Total=80. +// track 20 on pro: monthly(pro)=80→60. +// confirm override=30 → delta = 30-40 = -10 → unwind 10. +// LIFO: try monthly(free) — not found, skip. +// remaining_signed_unwind_value = -10 (positive lock, so negate). +// effective_additional = 0 + (-10) = -10 → refund 10 onto monthly(pro)=60→70. +// Final: monthly(pro)=70. Total=70. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-pg-edge PG-EC-3: lock on free, upgrade to pro, track, confirm refund — postgres path, skipped unwind redirects onto pro entitlement")}`, async () => { + const freeProd = makeFreeProd(); + const proProd = makeProProd(); + const customerId = "lock-pg-edge-3"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd, proProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey }); + + // check lock=40: deducts all 40 from monthly(free)=50→10. + // Receipt: [monthly(free):40] + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 40, + lock: { enabled: true, key: lockKey }, + skip_cache: true, + }); + + const afterCheck = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterCheck, + featureId: TestFeature.Messages, + remaining: 10, + }); + + // Upgrade free→pro. monthly(free) entitlement replaced by monthly(pro)=80. + // Lock receipt still references the now-gone monthly(free) ID. + await autumnV2_1.attach({ + customer_id: customerId, + product_id: proProd.id, + }); + + const afterUpgrade = + await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterUpgrade, + featureId: TestFeature.Messages, + remaining: 80, + }); + + // Track 20 on pro to consume some balance, giving room for the refund. + // monthly(pro)=80→60. + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 20, + }); + + await timeout(4000); + + // confirm override=30 → delta = 30-40 = -10 → unwind 10. + // LIFO: monthly(free) not found → skip, remaining_signed_unwind_value=-10. + // effective_additional = 0 + (-10) = -10 → refund 10 onto monthly(pro)=60→70. + await autumnV2_1.balances.finalize( + { + lock_key: lockKey, + action: "confirm", + override_value: 30, + }, + { + skipCache: true, + }, + ); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 70, + }); + + // Events newest-first: finalize(-10), track(20), check(40) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -10 }, { value: 20 }, { value: 40 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 70, + }); +}); diff --git a/server/tests/integration/balances/lock/postgres/check-with-lock-postgres-rollovers.test.ts b/server/tests/integration/balances/lock/postgres/check-with-lock-postgres-rollovers.test.ts new file mode 100644 index 000000000..c732f2bd8 --- /dev/null +++ b/server/tests/integration/balances/lock/postgres/check-with-lock-postgres-rollovers.test.ts @@ -0,0 +1,248 @@ +import { test } from "bun:test"; +import { type ApiCustomerV5, RolloverExpiryDurationType } from "@autumn/shared"; +import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ───────────────────────────────────────────────────────────────────────────── +// Lock + Rollover tests +// +// Product: monthlyMessages(100) with rolloverConfig { max: 200, length: 1, duration: Month } +// Rollovers are created via s.resetFeature() which directly triggers the reset +// cron logic without needing a test clock. +// +// Deduction order: oldest rollover first, then newer rollovers, then main entitlement. +// LIFO unwind (on confirm with refund): main entitlement first, then newer rollovers, +// then older rollovers — reverse of deduction order. +// +// RO-1: Refund crosses main→rollover[1] boundary (two rollover buckets) +// RO-2: Refund crosses main→rollover boundary (single rollover bucket) +// RO-3: Additional deduction crosses rollover→main boundary +// ───────────────────────────────────────────────────────────────────────────── + +const makeFreeProd = () => + products.base({ + id: "free", + items: [ + items.monthlyMessagesWithRollover({ + includedUsage: 100, + rolloverConfig: { + max: 200, + length: 1, + duration: RolloverExpiryDurationType.Month, + }, + }), + ], + }); + +// ───────────────────────────────────────────────────────────────────────────── +// RO-1: Refund across multiple rollover boundaries +// +// Setup: +// reset (no usage) → rollover[0]=100, main resets to 100. Total=200. +// reset (no usage) → rollover[1]=100, main resets to 100. Total=300 (r[0]=100, r[1]=100, main=100). +// +// check lock=250: +// Deduction order: r[0]=100 (exhausted), r[1]=100 (exhausted), main=50. +// After check: r[0]=0, r[1]=0, main=50. Total=50. +// Receipt: [r[0]:100, r[1]:100, main:50] +// +// confirm override=180 → delta = 180-250 = -70 → refund 70: +// LIFO: unwind main=50 fully → main=100. Remaining=20. +// Unwind r[1]=20 (of 100 in receipt) → r[1]=20. Remaining=0. r[0] stays 0. +// Final: r[0]=0, r[1]=20, main=100. Total=120. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-rollover RO-1: lock across two rollovers + main, confirm with refund crossing main→rollover[1] boundary")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-ro-1"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [ + s.attach({ productId: freeProd.id }), + // Cycle 1: no usage, reset → rollover[0]=100 + s.resetFeature({ featureId: TestFeature.Messages }), + // Cycle 2: no usage, reset → rollover[1]=100 + s.resetFeature({ featureId: TestFeature.Messages }), + ], + }); + + await deleteLock({ ctx, lockKey }); + + // Verify state after setup: r[0]=100, r[1]=100, main=100. Total=300. + const afterSetup = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterSetup, + featureId: TestFeature.Messages, + remaining: 300, + rollovers: [{ balance: 100 }, { balance: 100 }], + }); + + // check lock=250: exhausts r[0]=100, r[1]=100, deducts 50 from main. + // Receipt: [r[0]:100, r[1]:100, main:50] + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 250, + lock: { enabled: true, key: lockKey }, + skip_cache: true, + }); + + const afterCheck = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterCheck, + featureId: TestFeature.Messages, + remaining: 50, + rollovers: [{ balance: 0 }, { balance: 0 }], + }); + + // confirm override=180 → delta = -70 → refund 70. + // LIFO: unwind main=50 fully → main=100. Remaining=20. + // Unwind r[1]=20 → r[1]=20. r[0] stays 0. + // Final: r[0]=0, r[1]=20, main=100. Total=120. + await autumnV2_1.balances.finalize( + { + lock_key: lockKey, + action: "confirm", + override_value: 80, + }, + { skipCache: true }, + ); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 220, + rollovers: [{ balance: 20 }, { balance: 100 }], + }); + + // Events newest-first: finalize(-70), check(250). No track events (resets don't emit). + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -170 }, { value: 250 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 220, + rollovers: [{ balance: 20 }, { balance: 100 }], + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// RO-3: Additional deduction past rollover boundary +// +// Setup: +// reset (no usage) → rollover[0]=100, main resets to 100. Total=200. +// +// check lock=30: +// Deduction order: r[0]=30. After: r[0]=70, main=100. Total=170. +// Receipt: [r[0]:30] +// +// confirm override=150 → delta = 150-30 = +120 → additional deduction of 120. +// Full unwind of receipt: r[0] restored to 100. Then re-deduct 150 total. +// Re-deduct 150: r[0]=100→0, main=100→50. +// Final: r[0]=0, main=50. Total=50. +// +// Event value = override_value - locked_value = 150-30 = 120. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lock-rollover RO-3: lock within rollover, confirm with extra deduction past rollover→main boundary")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-ro-3"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [ + s.attach({ productId: freeProd.id }), + // No usage before reset → full rollover of 100 + s.resetFeature({ featureId: TestFeature.Messages }), + ], + }); + + await deleteLock({ ctx, lockKey }); + + // Verify state after setup: r[0]=100, main=100. Total=200. + const afterSetup = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterSetup, + featureId: TestFeature.Messages, + remaining: 200, + rollovers: [{ balance: 100 }], + }); + + // check lock=30: deducts 30 from r[0]. + // Receipt: [r[0]:30] + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 30, + lock: { enabled: true, key: lockKey }, + skip_cache: true, + }); + + const afterCheck = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterCheck, + featureId: TestFeature.Messages, + remaining: 170, + rollovers: [{ balance: 70 }], + }); + + // confirm override=150 → delta = +120 → additional deduction of 120. + // Fully unwind receipt (restore 30 to r[0] → r[0]=100), then re-deduct 150. + // Re-deduct 150: r[0]=100→0, main=100→50. + // Final: r[0]=0, main=50. Total=50. + await autumnV2_1.balances.finalize( + { + lock_key: lockKey, + action: "confirm", + override_value: 150, + }, + { skipCache: true }, + ); + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 50, + rollovers: [{ balance: 0 }], + }); + + // Events newest-first: finalize(delta=120), check(30). No track events. + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 120 }, { value: 30 }], + }); + + await timeout(3000); + + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 50, + rollovers: [{ balance: 0 }], + }); +}); diff --git a/server/tests/integration/balances/lock/postgres/check-with-lock-postgres.test.ts b/server/tests/integration/balances/lock/postgres/check-with-lock-postgres.test.ts new file mode 100644 index 000000000..005a49696 --- /dev/null +++ b/server/tests/integration/balances/lock/postgres/check-with-lock-postgres.test.ts @@ -0,0 +1,220 @@ +import { test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// Product: hourlyMessages(5) + monthlyMessages(10) = 15 total + +const makeFreeProd = () => { + const hourlyMessages = items.hourlyMessages({ includedUsage: 5 }); + const monthlyMessages = items.monthlyMessages({ includedUsage: 10 }); + return products.base({ + id: "free", + items: [hourlyMessages, monthlyMessages], + }); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// PG-1: skip_cache on check, normal confirm — partial refund +// check(skip_cache=true, required_balance=8) → deducts from Postgres path. +// confirm(override_value=5) → delta=-3 → unwind 3 → remaining=10. +// Assert both cached and DB balances match. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("postgres PG-1: skip_cache check + normal confirm — partial refund, remaining=10")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-pg-1"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + skip_cache: true, + }); + + await autumnV2_1.balances.finalize( + { + lock_key: customerId, + action: "confirm", + override_value: 5, + }, + { skipCache: true }, + ); + + // Cached balance + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 10, + }); + + // Events: finalize delta (-3), check (8) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -3 }, { value: 8 }], + }); + + // DB balance + await timeout(3000); + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 10, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// PG-2: skip_cache on finalize only — extra deduction confirm +// check(required_balance=8) → normal Redis deduction → remaining=7. +// confirm(skip_cache=true, override_value=11) → delta=+3 → Postgres deduction. +// remaining=15-11=4. Assert cached and DB balances. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("postgres PG-2: normal check + skip_cache finalize — extra deduction, remaining=4")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-pg-2"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + skip_cache: true, + }); + + await autumnV2_1.balances.finalize( + { + lock_key: customerId, + action: "confirm", + override_value: 11, + }, + { skipCache: true }, + ); + + // Cached balance + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 4, + }); + + // Events: finalize delta (+3), check (8) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 3 }, { value: 8 }], + }); + + // DB balance + await timeout(3000); + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 4, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// PG-3: skip_cache on both check and finalize — cross-bucket lock + refund +// check(skip_cache=true, required_balance=8) → Postgres deduction across +// hourly(5) + monthly(3). confirm(skip_cache=true, override_value=3) → +// delta=-5 → LIFO unwind: restore 3 from monthly, 2 from hourly. +// hourly=4, monthly=9 → remaining=13. +// Assert both cached and DB balances. +// ───────────────────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("postgres PG-3: skip_cache check + skip_cache finalize — cross-bucket refund, remaining=13")}`, async () => { + const freeProd = makeFreeProd(); + const customerId = "lock-pg-3"; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await deleteLock({ ctx, lockKey: customerId }); + + await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 8, + lock: { enabled: true, key: customerId }, + skip_cache: true, + }); + + await autumnV2_1.balances.finalize( + { + lock_key: customerId, + action: "confirm", + override_value: 3, + }, + { skipCache: true }, + ); + + // Cached balance: 15 - 3 = 12 — wait, hourly(5-2=3) + monthly(10-1=9) = 12 + // lock=8 deducts 5 from hourly + 3 from monthly. + // confirm=3: delta = 3-8 = -5 → unwind LIFO: restore 3 from monthly (→10), 2 from hourly (→4). + // Net: hourly=4, monthly=9 → total remaining=13. Wait: 4+9=13, not 12. Let's be precise: + // After lock: hourly=0, monthly=7 → total=7 + // After confirm(3): unwind -5 → restore 3 to monthly(→10) then 2 to hourly(→2) + // = hourly=2, monthly=10 → total=12? + // Re-derive: lock=8, hourly=5, monthly=10 → deduct 5 from hourly(→0), 3 from monthly(→7). Total=7. + // confirm=3, locked=8, delta=-5 → unwind -5 LIFO: restore 3 to monthly(→10) then 2 to hourly(→2). + // hourly=2, monthly=10 → remaining=12. + + const customer = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 12, + }); + + // Events: finalize delta (-5), check (8) + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -5 }, { value: 8 }], + }); + + // DB balance + await timeout(3000); + const customerDb = await autumnV2_1.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerDb, + featureId: TestFeature.Messages, + remaining: 12, + }); +}); diff --git a/server/tests/integration/balances/track/track-misc.test.ts b/server/tests/integration/balances/track/track-misc.test.ts index 9aa4c2802..9f1e63529 100644 --- a/server/tests/integration/balances/track/track-misc.test.ts +++ b/server/tests/integration/balances/track/track-misc.test.ts @@ -9,7 +9,7 @@ import { sumValues, type TrackResponseV2, } from "@autumn/shared"; -import { getCustomerEvents } from "@tests/balances/testBalanceUtils.js"; +import { getCustomerEvents } from "@tests/integration/balances/utils/events/getCustomerEvents.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; import { items } from "@tests/utils/fixtures/items.js"; diff --git a/server/tests/integration/balances/utils/events/expectCustomerEventsCorrect.ts b/server/tests/integration/balances/utils/events/expectCustomerEventsCorrect.ts new file mode 100644 index 000000000..a2108e0fd --- /dev/null +++ b/server/tests/integration/balances/utils/events/expectCustomerEventsCorrect.ts @@ -0,0 +1,24 @@ +import { expect } from "bun:test"; +import { timeout } from "@/utils/genUtils"; +import { getCustomerEvents } from "./getCustomerEvents.js"; + +/** + * Waits for event batching, fetches events (newest-first), then asserts values. + * For a check + confirm flow, events[0] = finalize delta (finalValue - lockValue), + * events[1] = track value from initial check (= requiredBalance). + */ +export const expectCustomerEventsCorrect = async ({ + customerId, + events: expectedEvents, +}: { + customerId: string; + events: { value: number }[]; +}) => { + await timeout(3000); + const events = await getCustomerEvents({ customerId }); + + expect(events).toHaveLength(expectedEvents.length); + for (let i = 0; i < expectedEvents.length; i++) { + expect(events[i].value).toBe(expectedEvents[i].value); + } +}; diff --git a/server/tests/integration/balances/utils/events/getCustomerEvents.ts b/server/tests/integration/balances/utils/events/getCustomerEvents.ts new file mode 100644 index 000000000..019abedab --- /dev/null +++ b/server/tests/integration/balances/utils/events/getCustomerEvents.ts @@ -0,0 +1,26 @@ +import { ApiVersion } from "@autumn/shared"; +import { AutumnInt } from "@server/external/autumn/autumnCli.js"; +import { EventService } from "@server/internal/api/events/EventService.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; + +export const getCustomerEvents = async ({ + customerId, +}: { + customerId: string; +}) => { + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + + const customer = await autumnV2.customers.get(customerId, { + with_autumn_id: true, + }); + + const events = await EventService.getByCustomerId({ + db: ctx.db, + orgId: ctx.org.id, + internalCustomerId: customer.autumn_id ?? "", + env: ctx.env, + limit: 10000, + }); + + return events; +}; diff --git a/server/tests/integration/balances/utils/lockUtils/expectLockReceiptDeleted.ts b/server/tests/integration/balances/utils/lockUtils/expectLockReceiptDeleted.ts new file mode 100644 index 000000000..8ae9b2cce --- /dev/null +++ b/server/tests/integration/balances/utils/lockUtils/expectLockReceiptDeleted.ts @@ -0,0 +1,23 @@ +import { expect } from "bun:test"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import { redis } from "@/external/redis/initRedis.js"; +import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js"; + +/** Asserts that the lock receipt for the given key no longer exists in Redis. */ +export const expectLockReceiptDeleted = async ({ + ctx, + lockKey, +}: { + ctx: TestContext; + lockKey: string; +}) => { + const hashedKey = Bun.hash(lockKey).toString(); + const redisReceiptKey = buildLockReceiptKey({ + orgId: ctx.org.id, + env: ctx.env, + lockKey: hashedKey, + }); + + const receipt = await redis.call("JSON.GET", redisReceiptKey, "$"); + expect(receipt).toBeNull(); +}; diff --git a/server/tests/integration/utils/expectBalanceCorrect.ts b/server/tests/integration/utils/expectBalanceCorrect.ts index e338a56a5..be7dfda76 100644 --- a/server/tests/integration/utils/expectBalanceCorrect.ts +++ b/server/tests/integration/utils/expectBalanceCorrect.ts @@ -1,15 +1,57 @@ import { expect } from "bun:test"; -import type { ApiCustomerV5 } from "@autumn/shared"; +import type { + ApiBalanceRollover, + ApiCustomerV5, + ResetInterval, +} from "@autumn/shared"; + +type BucketExpectation = { + included_grant?: number; + remaining?: number; + usage?: number; +}; + +// Keys are ResetInterval values (eg. "hour", "month") or "lifetime" for null-reset buckets. +type BreakdownExpectation = Partial< + Record +>; export const expectBalanceCorrect = ({ customer, featureId, remaining, + breakdown, + rollovers, }: { customer: ApiCustomerV5; featureId: string; remaining: number; + breakdown?: BreakdownExpectation; + /** Expected rollovers in order (oldest first). Only specified fields are checked. */ + rollovers?: Partial[]; }) => { expect(customer.balances[featureId]).toBeDefined(); expect(customer.balances[featureId].remaining).toBe(remaining); + + if (breakdown) { + const buckets = customer.balances[featureId]?.breakdown; + expect(buckets).toBeDefined(); + + for (const [key, expectation] of Object.entries(breakdown)) { + const bucket = + key === "lifetime" + ? buckets?.find((b) => b.reset === null) + : buckets?.find((b) => b.reset?.interval === key); + expect(bucket).toBeDefined(); + expect(bucket).toMatchObject(expectation as BucketExpectation); + } + } + + if (rollovers) { + const actual = customer.balances[featureId]?.rollovers; + expect(actual?.length).toBe(rollovers.length); + for (let i = 0; i < rollovers.length; i++) { + expect(actual![i]).toMatchObject(rollovers[i]); + } + } }; diff --git a/server/tsconfig.json b/server/tsconfig.json index ec58b0b37..ca7efbd80 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -8,6 +8,8 @@ "target": "ES2020", "moduleResolution": "bundler", "module": "Preserve", + "typeRoots": ["./node_modules/@types", "../node_modules/@types"], + "types": ["bun", "node"], // "declaration": true, "jsx": "react-jsx", diff --git a/shared/api/balances/check/checkParams.ts b/shared/api/balances/check/checkParams.ts index 9c0814b94..7f86566d0 100644 --- a/shared/api/balances/check/checkParams.ts +++ b/shared/api/balances/check/checkParams.ts @@ -3,7 +3,7 @@ import { CustomerDataSchema } from "../../common/customerData"; import { EntityDataSchema } from "../../common/entityData"; import { queryStringArray } from "../../common/queryHelpers"; import { BalanceParamsBaseSchema } from "../common/balanceParamsBase"; -import { LockParamsSchema } from "../common/lockParams"; +import { LockParamsSchema, ParsedLockParamsSchema } from "../common/lockParams"; import { CheckExpand } from "./enums/CheckExpand"; export const CheckQuerySchema = z.object({ @@ -73,6 +73,10 @@ export const CheckParamsSchema = ExtCheckParamsSchema.extend({ }, ); -export type CheckParams = z.infer; +export const ParsedCheckParamsSchema = CheckParamsSchema.extend({ + lock: ParsedLockParamsSchema.optional(), +}); +export type CheckParams = z.infer; +export type ParsedCheckParams = z.infer; export type CheckQuery = z.infer; diff --git a/shared/api/balances/check/checkResponseV3.ts b/shared/api/balances/check/checkResponseV3.ts index 3b9695b4a..e9b8bcf5c 100644 --- a/shared/api/balances/check/checkResponseV3.ts +++ b/shared/api/balances/check/checkResponseV3.ts @@ -27,10 +27,10 @@ export const CheckResponseV3Schema = z.object({ "The customer's balance for this feature. Null if the customer has no balance for this feature.", }), - lock_key: z.string().optional().meta({ - description: - "The lock key associated with this check when lock mode is enabled.", - }), + // lock_key: z.string().optional().meta({ + // description: + // "The lock key associated with this check when lock mode is enabled.", + // }), preview: CheckFeaturePreviewSchema.optional().meta({ description: diff --git a/shared/api/balances/common/lockParams.ts b/shared/api/balances/common/lockParams.ts index ed5aba3be..41e27bdbb 100644 --- a/shared/api/balances/common/lockParams.ts +++ b/shared/api/balances/common/lockParams.ts @@ -3,14 +3,20 @@ import { z } from "zod/v4"; export const LockParamsSchema = z .object({ enabled: z.literal(true), - key: z.string().max(256).optional(), + key: z.string().max(256), hashed_key: z.string().optional().meta({ internal: true, }), - expires_at: z.string().optional(), + expires_at: z.number().optional(), }) .meta({ internal: true, }); +export const ParsedLockParamsSchema = LockParamsSchema.extend({ + key: z.string().max(256), + hashed_key: z.string(), +}); + export type LockParams = z.infer; +export type ParsedLockParams = z.infer; diff --git a/shared/api/balances/finalizeLock/finalizeLockParamsV0.ts b/shared/api/balances/finalizeLock/finalizeLockParamsV0.ts index 368e6263f..83ff533fb 100644 --- a/shared/api/balances/finalizeLock/finalizeLockParamsV0.ts +++ b/shared/api/balances/finalizeLock/finalizeLockParamsV0.ts @@ -2,8 +2,8 @@ import { z } from "zod/v4"; export const FinalizeLockParamsV0Schema = z.object({ lock_key: z.string(), - finalize_action: z.enum(["confirm", "release"]), - overwrite_value: z.number(), + action: z.enum(["confirm", "release"]), + override_value: z.number().optional(), }); export type FinalizeLockParamsV0 = z.infer;