From 8b37eb464dd6a6b1ba25a34ab31d5d2c223ede53 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:42:03 +0100 Subject: [PATCH 1/4] feat(billing): create a wallet for every account and make start-trial idempotent --- apps/api/drizzle/0033_long_solo.sql | 2 + apps/api/drizzle/meta/0033_snapshot.json | 1361 +++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + .../user-wallet/user-wallet.schema.ts | 2 +- .../user-wallet.repository.integration.ts | 53 + .../user-wallet/user-wallet.repository.ts | 15 +- .../routes/start-trial/start-trial.router.ts | 3 + .../managed-signer/managed-signer.service.ts | 2 +- .../managed-user-wallet.service.spec.ts | 33 +- .../managed-user-wallet.service.ts | 7 +- .../services/refill/refill.service.spec.ts | 35 +- .../billing/services/refill/refill.service.ts | 16 +- .../trial-validation.service.spec.ts | 6 + .../trial-validation.service.ts | 4 +- .../wallet-initializer.service.spec.ts | 153 +- .../wallet-initializer.service.ts | 57 +- .../wallet-reader.service.spec.ts | 56 + .../wallet-reader/wallet-reader.service.ts | 2 +- .../deployment-setting.repository.ts | 4 +- ...top-up-managed-deployments.service.spec.ts | 3 +- .../top-up-managed-deployments.service.ts | 4 +- .../user/user.repository.integration.ts | 26 +- .../user/repositories/user/user.repository.ts | 4 +- .../services/user/user.service.integration.ts | 11 +- .../user/services/user/user.service.spec.ts | 48 +- .../src/user/services/user/user.service.ts | 8 +- .../__snapshots__/docs.spec.ts.snap | 3 + .../test/functional/wallets-refill.spec.ts | 50 +- .../seeders/auto-top-up-deployment.seeder.ts | 1 + apps/api/test/seeders/user-wallet.seeder.ts | 6 +- 30 files changed, 1840 insertions(+), 142 deletions(-) create mode 100644 apps/api/drizzle/0033_long_solo.sql create mode 100644 apps/api/drizzle/meta/0033_snapshot.json create mode 100644 apps/api/src/billing/repositories/user-wallet/user-wallet.repository.integration.ts create mode 100644 apps/api/src/billing/services/wallet-reader/wallet-reader.service.spec.ts diff --git a/apps/api/drizzle/0033_long_solo.sql b/apps/api/drizzle/0033_long_solo.sql new file mode 100644 index 0000000000..208ce42797 --- /dev/null +++ b/apps/api/drizzle/0033_long_solo.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user_wallets" ADD COLUMN "activated_at" timestamp with time zone;--> statement-breakpoint +UPDATE "user_wallets" SET "activated_at" = "created_at" WHERE "address" IS NOT NULL; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0033_snapshot.json b/apps/api/drizzle/meta/0033_snapshot.json new file mode 100644 index 0000000000..6b6ed86e6a --- /dev/null +++ b/apps/api/drizzle/meta/0033_snapshot.json @@ -0,0 +1,1361 @@ +{ + "id": "0e3866c6-bb02-4bb0-8241-6e6ac040f4f7", + "prevId": "c149bb61-3fb8-43b7-9eef-1ee778d722c5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user_wallets": { + "name": "user_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "deployment_allowance": { + "name": "deployment_allowance", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "fee_allowance": { + "name": "fee_allowance", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_wallets_user_id_userSetting_id_fk": { + "name": "user_wallets_user_id_userSetting_id_fk", + "tableFrom": "user_wallets", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_wallets_user_id_unique": { + "name": "user_wallets_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "user_wallets_address_unique": { + "name": "user_wallets_address_unique", + "nullsNotDistinct": false, + "columns": [ + "address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_validated": { + "name": "is_validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_methods_fingerprint_payment_method_id_unique": { + "name": "payment_methods_fingerprint_payment_method_id_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_is_default_unique": { + "name": "payment_methods_user_id_is_default_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"payment_methods\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_fingerprint_idx": { + "name": "payment_methods_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_idx": { + "name": "payment_methods_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_is_validated_idx": { + "name": "payment_methods_user_id_is_validated_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_validated", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_fingerprint_payment_method_id_idx": { + "name": "payment_methods_user_id_fingerprint_payment_method_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_user_id_userSetting_id_fk": { + "name": "payment_methods_user_id_userSetting_id_fk", + "tableFrom": "payment_methods", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_transactions": { + "name": "stripe_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "stripe_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "stripe_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_refunded": { + "name": "amount_refunded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bonus_amount": { + "name": "bonus_amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_coupon_id": { + "name": "stripe_coupon_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_promotion_code_id": { + "name": "stripe_promotion_code_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_idempotency_key": { + "name": "stripe_idempotency_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "card_brand": { + "name": "card_brand", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "card_last4": { + "name": "card_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false + }, + "receipt_url": { + "name": "receipt_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stripe_transactions_stripe_invoice_id_unique": { + "name": "stripe_transactions_stripe_invoice_id_unique", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"stripe_transactions\".\"stripe_invoice_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_idempotency_key_unique": { + "name": "stripe_transactions_stripe_idempotency_key_unique", + "columns": [ + { + "expression": "stripe_idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"stripe_transactions\".\"stripe_idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_user_id_idx": { + "name": "stripe_transactions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_payment_intent_id_idx": { + "name": "stripe_transactions_stripe_payment_intent_id_idx", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_charge_id_idx": { + "name": "stripe_transactions_stripe_charge_id_idx", + "columns": [ + { + "expression": "stripe_charge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_coupon_id_idx": { + "name": "stripe_transactions_stripe_coupon_id_idx", + "columns": [ + { + "expression": "stripe_coupon_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_promotion_code_id_idx": { + "name": "stripe_transactions_stripe_promotion_code_id_idx", + "columns": [ + { + "expression": "stripe_promotion_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_status_idx": { + "name": "stripe_transactions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_created_at_idx": { + "name": "stripe_transactions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_user_id_created_at_idx": { + "name": "stripe_transactions_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_transactions_user_id_userSetting_id_fk": { + "name": "stripe_transactions_user_id_userSetting_id_fk", + "tableFrom": "stripe_transactions", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_settings": { + "name": "wallet_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "auto_reload_enabled": { + "name": "auto_reload_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_settings_user_id_idx": { + "name": "wallet_settings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_settings_wallet_id_user_wallets_id_fk": { + "name": "wallet_settings_wallet_id_user_wallets_id_fk", + "tableFrom": "wallet_settings", + "tableTo": "user_wallets", + "columnsFrom": [ + "wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wallet_settings_user_id_userSetting_id_fk": { + "name": "wallet_settings_user_id_userSetting_id_fk", + "tableFrom": "wallet_settings", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_settings_wallet_id_unique": { + "name": "wallet_settings_wallet_id_unique", + "nullsNotDistinct": false, + "columns": [ + "wallet_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.userSetting": { + "name": "userSetting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscribedToNewsletter": { + "name": "subscribedToNewsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "youtubeUsername": { + "name": "youtubeUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "twitterUsername": { + "name": "twitterUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "githubUsername": { + "name": "githubUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_ip": { + "name": "last_ip", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_user_agent": { + "name": "last_user_agent", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "last_fingerprint": { + "name": "last_fingerprint", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "userSetting_userId_unique": { + "name": "userSetting_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "userSetting_username_unique": { + "name": "userSetting_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template": { + "name": "template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "copiedFromId": { + "name": "copiedFromId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cpu": { + "name": "cpu", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "storage": { + "name": "storage", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sdl": { + "name": "sdl", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "template_userId_idx": { + "name": "template_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templateFavorite": { + "name": "templateFavorite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "templateId": { + "name": "templateId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "addedDate": { + "name": "addedDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "templateFavorite_userId_templateId_unique": { + "name": "templateFavorite_userId_templateId_unique", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "templateId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templateFavorite_templateId_template_id_fk": { + "name": "templateFavorite_templateId_template_id_fk", + "tableFrom": "templateFavorite", + "tableTo": "template", + "columnsFrom": [ + "templateId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_settings": { + "name": "deployment_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dseq": { + "name": "dseq", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "auto_top_up_enabled": { + "name": "auto_top_up_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "closed": { + "name": "closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "id_auto_top_up_enabled_closed_idx": { + "name": "id_auto_top_up_enabled_closed_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "auto_top_up_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "closed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_settings_user_id_userSetting_id_fk": { + "name": "deployment_settings_user_id_userSetting_id_fk", + "tableFrom": "deployment_settings", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dseq_user_id_idx": { + "name": "dseq_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "dseq", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hashed_key": { + "name": "hashed_key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key_format": { + "name": "key_format", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_userSetting_id_fk": { + "name": "api_keys_user_id_userSetting_id_fk", + "tableFrom": "api_keys", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_unique": { + "name": "api_keys_hashed_key_unique", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verification_codes": { + "name": "email_verification_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_verification_codes_user_id_idx": { + "name": "email_verification_codes_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_verification_codes_user_id_userSetting_id_fk": { + "name": "email_verification_codes_user_id_userSetting_id_fk", + "tableFrom": "email_verification_codes", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.stripe_transaction_status": { + "name": "stripe_transaction_status", + "schema": "public", + "values": [ + "created", + "pending", + "requires_action", + "succeeded", + "failed", + "refunded", + "canceled" + ] + }, + "public.stripe_transaction_type": { + "name": "stripe_transaction_type", + "schema": "public", + "values": [ + "payment_intent", + "coupon_claim", + "manual_credit" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 138e037e49..428d24e33b 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1784238593894, "tag": "0032_massive_wolverine", "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1784819171143, + "tag": "0033_long_solo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts b/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts index 378d765894..7acbb989a1 100644 --- a/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts +++ b/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts @@ -1,6 +1,5 @@ import { boolean, numeric, pgTable, serial, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; -// eslint-disable-next-line import-x/no-cycle import { Users } from "@src/user/model-schemas/user/user.schema"; export const UserWallets = pgTable("user_wallets", { @@ -13,6 +12,7 @@ export const UserWallets = pgTable("user_wallets", { deploymentAllowance: allowance("deployment_allowance"), feeAllowance: allowance("fee_allowance"), isTrialing: boolean("trial").default(true), + activatedAt: timestamp("activated_at", { withTimezone: true }), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull() }); diff --git a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.integration.ts b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.integration.ts new file mode 100644 index 0000000000..df25af5159 --- /dev/null +++ b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.integration.ts @@ -0,0 +1,53 @@ +import { faker } from "@faker-js/faker"; +import { container } from "tsyringe"; +import { describe, expect, it } from "vitest"; + +import { UserRepository } from "@src/user/repositories"; +import { UserWalletRepository } from "./user-wallet.repository"; + +import { createAkashAddress } from "@test/seeders/akash-address.seeder"; + +describe(UserWalletRepository.name, () => { + describe("claimActivation", () => { + it("claims activation exactly once across concurrent attempts", async () => { + const { userWalletRepository, wallet } = await setup(); + + const results = await Promise.all(Array.from({ length: 5 }, () => userWalletRepository.claimActivation(wallet.id))); + + const claimed = results.filter(Boolean); + expect(claimed).toHaveLength(1); + expect(claimed[0]?.activatedAt).toBeInstanceOf(Date); + }); + + it("returns undefined when the wallet is already activated", async () => { + const { userWalletRepository, wallet } = await setup(); + + const first = await userWalletRepository.claimActivation(wallet.id); + const second = await userWalletRepository.claimActivation(wallet.id); + + expect(first?.activatedAt).toBeInstanceOf(Date); + expect(second).toBeUndefined(); + }); + + it("claims again after activation is unset", async () => { + const { userWalletRepository, wallet } = await setup(); + + const first = await userWalletRepository.claimActivation(wallet.id); + await userWalletRepository.updateById(wallet.id, { activatedAt: null }); + const second = await userWalletRepository.claimActivation(wallet.id); + + expect(first?.activatedAt).toBeInstanceOf(Date); + expect(second?.activatedAt).toBeInstanceOf(Date); + }); + }); + + async function setup() { + const userRepository = container.resolve(UserRepository); + const userWalletRepository = container.resolve(UserWalletRepository); + + const user = await userRepository.create({ userId: faker.string.uuid() }); + const wallet = await userWalletRepository.create({ userId: user.id, address: createAkashAddress() }); + + return { userRepository, userWalletRepository, user, wallet }; + } +}); diff --git a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts index 6ce332a96a..dfbdd34656 100644 --- a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts +++ b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts @@ -1,6 +1,6 @@ import { Trace } from "@akashnetwork/instrumentation"; import subDays from "date-fns/subDays"; -import { and, count, eq, gt, inArray, lte, or } from "drizzle-orm"; +import { and, count, eq, gt, inArray, isNotNull, isNull, lte, or } from "drizzle-orm"; import { singleton } from "tsyringe"; import { type ApiPgDatabase, type ApiPgTables, InjectPg, InjectPgTable } from "@src/core/providers"; @@ -82,6 +82,16 @@ export class UserWalletRepository extends BaseRepository { + const [claimed] = await this.cursor + .update(this.table) + .set({ activatedAt: new Date() }) + .where(and(eq(this.table.id, id), isNull(this.table.activatedAt))) + .returning(); + + return claimed ? this.toOutput(claimed) : undefined; + } + async findDrainingWallets(thresholds: { fee: number; trialExpirationDays: number }) { const trialWindowStart = subDays(new Date(), thresholds.trialExpirationDays); @@ -89,8 +99,9 @@ export class UserWalletRepository extends BaseRepository): Promise { + async ensureFeeGrants(wallet: Pick): Promise { return withSpan("ManagedSignerService.ensureFeeGrants", async () => { let feeAllowance = await this.balancesService.retrieveAndCalcFeeLimit(wallet); const needsRefill = feeAllowance < this.billingConfigService.get("FEE_ALLOWANCE_REFILL_THRESHOLD"); diff --git a/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.spec.ts b/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.spec.ts index 8c49dc06cb..d03317f4c5 100644 --- a/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.spec.ts +++ b/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.spec.ts @@ -20,14 +20,16 @@ describe(ManagedUserWalletService.name, () => { it("throws 402 when wallet address is missing", async () => { const { service, signer } = setup(); - await expect(service.refillWalletFees(signer, { address: null, isTrialing: false, createdAt: faker.date.past() })).rejects.toMatchObject({ + await expect( + service.refillWalletFees(signer, { address: null, isTrialing: false, createdAt: faker.date.past(), activatedAt: null }) + ).rejects.toMatchObject({ status: 402 }); }); it("authorizes fee spending without expiration for non-trialing wallet", async () => { const { service, signer, config, rpcMessageService } = setup(); - const wallet = { address: createAkashAddress(), isTrialing: false, createdAt: faker.date.past() }; + const wallet = { address: createAkashAddress(), isTrialing: false, createdAt: faker.date.past(), activatedAt: null }; await service.refillWalletFees(signer, wallet); @@ -43,7 +45,7 @@ describe(ManagedUserWalletService.name, () => { it("authorizes fee spending with expiration for trialing wallet in trial window", async () => { const { service, signer, config, rpcMessageService } = setup(); const createdAt = subDays(new Date(), 1); - const wallet = { address: createAkashAddress(), isTrialing: true, createdAt }; + const wallet = { address: createAkashAddress(), isTrialing: true, createdAt, activatedAt: null }; await service.refillWalletFees(signer, wallet); @@ -58,7 +60,7 @@ describe(ManagedUserWalletService.name, () => { it("authorizes fee spending without expiration for trialing wallet outside trial window", async () => { const { service, signer, config, rpcMessageService } = setup(); const createdAt = subDays(new Date(), config.TRIAL_ALLOWANCE_EXPIRATION_DAYS + 1); - const wallet = { address: createAkashAddress(), isTrialing: true, createdAt }; + const wallet = { address: createAkashAddress(), isTrialing: true, createdAt, activatedAt: null }; await service.refillWalletFees(signer, wallet); @@ -68,12 +70,27 @@ describe(ManagedUserWalletService.name, () => { }) ); }); + + it("anchors the trial window to activation time when the wallet was created long before the trial started", async () => { + const { service, signer, config, rpcMessageService } = setup(); + const createdAt = subDays(new Date(), config.TRIAL_ALLOWANCE_EXPIRATION_DAYS + 30); + const activatedAt = subDays(new Date(), 1); + const wallet = { address: createAkashAddress(), isTrialing: true, createdAt, activatedAt }; + + await service.refillWalletFees(signer, wallet); + + expect(rpcMessageService.getFeesAllowanceGrantMsg).toHaveBeenCalledWith( + expect.objectContaining({ + expiration: addDays(activatedAt, config.TRIAL_ALLOWANCE_EXPIRATION_DAYS) + }) + ); + }); }); describe("fee grant silent-drop recovery", () => { it("verifies the grant landed after a batched revoke+grant tx", async () => { const { service, signer, rpcMessageService, authzHttpService, logger } = setup(); - const wallet = { address: createAkashAddress(), isTrialing: false, createdAt: faker.date.past() }; + const wallet = { address: createAkashAddress(), isTrialing: false, createdAt: faker.date.past(), activatedAt: null }; authzHttpService.hasFeeAllowance.mockResolvedValueOnce(true).mockResolvedValueOnce(true); @@ -88,7 +105,7 @@ describe(ManagedUserWalletService.name, () => { it("re-issues the grant when the post-tx check reports it missing", async () => { const { service, signer, rpcMessageService, authzHttpService, logger } = setup(); - const wallet = { address: createAkashAddress(), isTrialing: false, createdAt: faker.date.past() }; + const wallet = { address: createAkashAddress(), isTrialing: false, createdAt: faker.date.past(), activatedAt: null }; const grantMsg = { typeUrl: "/fee-grant", value: {} } as unknown as EncodeObject; rpcMessageService.getFeesAllowanceGrantMsg.mockReturnValue(grantMsg); @@ -109,7 +126,7 @@ describe(ManagedUserWalletService.name, () => { it("throws BadGateway after the retry policy is exhausted", async () => { const { service, signer, authzHttpService, logger } = setup(); - const wallet = { address: createAkashAddress(), isTrialing: false, createdAt: faker.date.past() }; + const wallet = { address: createAkashAddress(), isTrialing: false, createdAt: faker.date.past(), activatedAt: null }; authzHttpService.hasFeeAllowance.mockResolvedValueOnce(true).mockResolvedValue(false); @@ -123,7 +140,7 @@ describe(ManagedUserWalletService.name, () => { it("skips post-tx verification when no prior allowance existed", async () => { const { service, signer, rpcMessageService, authzHttpService, logger } = setup(); - const wallet = { address: createAkashAddress(), isTrialing: false, createdAt: faker.date.past() }; + const wallet = { address: createAkashAddress(), isTrialing: false, createdAt: faker.date.past(), activatedAt: null }; authzHttpService.hasFeeAllowance.mockResolvedValue(false); diff --git a/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts b/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts index 3eaf089b90..cc0b1de676 100644 --- a/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts +++ b/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts @@ -105,12 +105,13 @@ export class ManagedUserWalletService { * @param userWallet - The user wallet to refill fees for */ @Trace() - async refillWalletFees(signer: ManagedSignerService, wallet: Pick) { + async refillWalletFees(signer: ManagedSignerService, wallet: Pick) { assert(wallet.address, 402, "Wallet is not initialized"); + const trialStartedAt = wallet.activatedAt ?? wallet.createdAt; const trialWindowStart = subDays(new Date(), this.config.TRIAL_ALLOWANCE_EXPIRATION_DAYS); - const isInTrialWindow = wallet.isTrialing && isAfter(wallet.createdAt, trialWindowStart); - const expiration = isInTrialWindow ? addDays(wallet.createdAt, this.config.TRIAL_ALLOWANCE_EXPIRATION_DAYS) : undefined; + const isInTrialWindow = wallet.isTrialing && isAfter(trialStartedAt, trialWindowStart); + const expiration = isInTrialWindow ? addDays(trialStartedAt, this.config.TRIAL_ALLOWANCE_EXPIRATION_DAYS) : undefined; const fees = this.config.FEE_ALLOWANCE_REFILL_AMOUNT; await this.authorizeSpending(signer, { diff --git a/apps/api/src/billing/services/refill/refill.service.spec.ts b/apps/api/src/billing/services/refill/refill.service.spec.ts index a9fd683455..e0d0a993a2 100644 --- a/apps/api/src/billing/services/refill/refill.service.spec.ts +++ b/apps/api/src/billing/services/refill/refill.service.spec.ts @@ -16,32 +16,33 @@ describe(RefillService.name, () => { const userId = "test-user-id"; const amountUsd = 100; - it("should top up existing wallet", async () => { + it("should top up existing activated wallet", async () => { const { service, userWalletRepository, managedUserWalletService, managedSignerService, balancesService, walletInitializerService, analyticsService } = setup(); const existingWallet = createUserWallet({ userId }); - userWalletRepository.findOneBy.mockResolvedValue(existingWallet); + walletInitializerService.ensureWallet.mockResolvedValue(existingWallet); + userWalletRepository.claimActivation.mockResolvedValue(undefined); managedUserWalletService.authorizeSpending.mockResolvedValue(); balancesService.retrieveDeploymentLimit.mockResolvedValue(5000); balancesService.refreshUserWalletLimits.mockResolvedValue(); await service.topUpWallet(amountUsd, userId); - expect(userWalletRepository.findOneBy).toHaveBeenCalledWith({ userId }); + expect(walletInitializerService.ensureWallet).toHaveBeenCalledWith(userId); expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledWith(managedSignerService, { address: existingWallet.address, limits: { deployment: 1005000, fees: 1000 } }); expect(balancesService.retrieveDeploymentLimit).toHaveBeenCalledWith(existingWallet); expect(balancesService.refreshUserWalletLimits).toHaveBeenCalledWith(existingWallet, { endTrial: true }); - expect(walletInitializerService.initialize).not.toHaveBeenCalled(); expect(analyticsService.track).toHaveBeenCalledWith(userId, "balance_top_up", expect.objectContaining({ amount_cents: amountUsd, amount_usd: 1 })); }); it("attaches payment context to the balance_top_up analytics event", async () => { - const { service, userWalletRepository, managedUserWalletService, balancesService, analyticsService } = setup(); + const { service, userWalletRepository, managedUserWalletService, balancesService, analyticsService, walletInitializerService } = setup(); const existingWallet = createUserWallet({ userId }); - userWalletRepository.findOneBy.mockResolvedValue(existingWallet); + walletInitializerService.ensureWallet.mockResolvedValue(existingWallet); + userWalletRepository.claimActivation.mockResolvedValue(undefined); managedUserWalletService.authorizeSpending.mockResolvedValue(); balancesService.retrieveDeploymentLimit.mockResolvedValue(5000); balancesService.refreshUserWalletLimits.mockResolvedValue(); @@ -70,9 +71,10 @@ describe(RefillService.name, () => { }); it("does not end trial when endTrial option is false", async () => { - const { service, userWalletRepository, managedUserWalletService, balancesService } = setup(); + const { service, userWalletRepository, managedUserWalletService, balancesService, walletInitializerService } = setup(); const existingWallet = createUserWallet({ userId }); - userWalletRepository.findOneBy.mockResolvedValue(existingWallet); + walletInitializerService.ensureWallet.mockResolvedValue(existingWallet); + userWalletRepository.claimActivation.mockResolvedValue(undefined); managedUserWalletService.authorizeSpending.mockResolvedValue(); balancesService.retrieveDeploymentLimit.mockResolvedValue(5000); balancesService.refreshUserWalletLimits.mockResolvedValue(); @@ -82,25 +84,26 @@ describe(RefillService.name, () => { expect(balancesService.refreshUserWalletLimits).toHaveBeenCalledWith(existingWallet, { endTrial: false }); }); - it("should create new wallet when none exists", async () => { + it("activates a non-activated wallet on first funding", async () => { const { service, userWalletRepository, walletInitializerService, balancesService, managedUserWalletService, managedSignerService, analyticsService } = setup(); - const newWallet = createUserWallet({ userId }); - userWalletRepository.findOneBy.mockResolvedValue(undefined); - walletInitializerService.initialize.mockResolvedValue(newWallet); + const wallet = createUserWallet({ userId, activatedAt: null }); + const activatedWallet = { ...wallet, activatedAt: new Date() }; + walletInitializerService.ensureWallet.mockResolvedValue(wallet); + userWalletRepository.claimActivation.mockResolvedValue(activatedWallet); managedUserWalletService.authorizeSpending.mockResolvedValue(); balancesService.retrieveDeploymentLimit.mockResolvedValue(0); balancesService.refreshUserWalletLimits.mockResolvedValue(); await service.topUpWallet(amountUsd, userId); - expect(userWalletRepository.findOneBy).toHaveBeenCalledWith({ userId }); + expect(walletInitializerService.ensureWallet).toHaveBeenCalledWith(userId); + expect(userWalletRepository.claimActivation).toHaveBeenCalledWith(wallet.id); expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledWith(managedSignerService, { - address: newWallet.address, + address: wallet.address, limits: { deployment: 1000000, fees: 1000 } }); - expect(balancesService.refreshUserWalletLimits).toHaveBeenCalledWith(newWallet, { endTrial: true }); - expect(walletInitializerService.initialize).toHaveBeenCalledWith(userId); + expect(balancesService.refreshUserWalletLimits).toHaveBeenCalledWith(activatedWallet, { endTrial: true }); expect(analyticsService.track).toHaveBeenCalledWith(userId, "balance_top_up", expect.objectContaining({ amount_cents: amountUsd, amount_usd: 1 })); }); diff --git a/apps/api/src/billing/services/refill/refill.service.ts b/apps/api/src/billing/services/refill/refill.service.ts index a61f13d936..723c7a42a1 100644 --- a/apps/api/src/billing/services/refill/refill.service.ts +++ b/apps/api/src/billing/services/refill/refill.service.ts @@ -55,9 +55,10 @@ export class RefillService { private async refillWalletFees(userWallet: UserWalletOutput) { const trialWindowStart = subDays(new Date(), this.config.TRIAL_ALLOWANCE_EXPIRATION_DAYS); - const isInTrialWindow = userWallet.isTrialing && userWallet.createdAt && userWallet.createdAt > trialWindowStart; + const trialStartedAt = userWallet.activatedAt ?? userWallet.createdAt; + const isInTrialWindow = userWallet.isTrialing && trialStartedAt && trialStartedAt > trialWindowStart; - const expiration = isInTrialWindow && userWallet.createdAt ? addDays(userWallet.createdAt, this.config.TRIAL_ALLOWANCE_EXPIRATION_DAYS) : undefined; + const expiration = isInTrialWindow && trialStartedAt ? addDays(trialStartedAt, this.config.TRIAL_ALLOWANCE_EXPIRATION_DAYS) : undefined; await this.managedUserWalletService.authorizeSpending(this.managedSignerService, { address: userWallet.address!, @@ -136,12 +137,13 @@ export class RefillService { this.logger.info({ event: "WALLET_BALANCE_REDUCED", userId, amountUsd, previousLimit: currentLimit, nextLimit }); } + /** + * Funding a wallet with real money activates it even when the user never started a trial, + * so `claimActivation` is attempted on every top-up and silently no-ops for already-activated wallets. + */ private async getOrCreateUserWallet(userId: UserWalletOutput["userId"]) { - const userWallet = await this.userWalletRepository.findOneBy({ userId }); - if (userWallet) { - return userWallet; - } + const userWallet = await this.walletInitializerService.ensureWallet(userId); - return await this.walletInitializerService.initialize(userId); + return (await this.userWalletRepository.claimActivation(userWallet.id)) ?? userWallet; } } diff --git a/apps/api/src/billing/services/trial-validation/trial-validation.service.spec.ts b/apps/api/src/billing/services/trial-validation/trial-validation.service.spec.ts index 14e80b876f..2475ad9a9d 100644 --- a/apps/api/src/billing/services/trial-validation/trial-validation.service.spec.ts +++ b/apps/api/src/billing/services/trial-validation/trial-validation.service.spec.ts @@ -207,6 +207,12 @@ describe(TrialValidationService.name, () => { expect(() => service.validateTopUpAmount(undefined, 1)).not.toThrow(); }); + it("resolves for a registration-created wallet that was never activated", () => { + const { service } = setupTopUp({ trialMin: 100 }); + const wallet = createUserWallet({ isTrialing: true, activatedAt: null }); + expect(() => service.validateTopUpAmount(wallet, 1)).not.toThrow(); + }); + it("resolves for non-trial users paying at or above the standard minimum", () => { const { service } = setupTopUp({ trialMin: 100 }); const wallet = createUserWallet({ isTrialing: false }); diff --git a/apps/api/src/billing/services/trial-validation/trial-validation.service.ts b/apps/api/src/billing/services/trial-validation/trial-validation.service.ts index ca347a6079..a68514cb9d 100644 --- a/apps/api/src/billing/services/trial-validation/trial-validation.service.ts +++ b/apps/api/src/billing/services/trial-validation/trial-validation.service.ts @@ -74,8 +74,8 @@ export class TrialValidationService { return userWallet.isTrialing ? this.config.get("MANAGED_WALLET_TRIAL_MIN_TOP_UP_AMOUNT") : STANDARD_TOP_UP_MIN_AMOUNT_USD; } - validateTopUpAmount(userWallet: Pick | undefined, amountUsd: number) { - if (!userWallet) return; + validateTopUpAmount(userWallet: Pick | undefined, amountUsd: number) { + if (!userWallet?.activatedAt) return; const min = this.getTopUpMinAmountUsd(userWallet); assert( amountUsd >= min, diff --git a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts index 762b096218..7bcd37cfd0 100644 --- a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts +++ b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts @@ -41,10 +41,11 @@ describe(WalletInitializerService.name, () => { it("goes straight to wallet init without requiring a payment method", async () => { const user = createUser({ emailVerified: true, stripeCustomerId: faker.string.uuid() }); - const newWallet = createUserWallet({ userId: user.id }); + const newWallet = createUserWallet({ userId: user.id, activatedAt: null }); const di = setup({ user, getOrCreateWallet: vi.fn().mockResolvedValue({ wallet: newWallet, isNew: true }), + claimActivation: vi.fn().mockResolvedValue({ ...newWallet, activatedAt: new Date() }), updateWalletById: vi.fn().mockResolvedValue(newWallet) }); const managedUserWalletService = di.resolve(ManagedUserWalletService) as MockProxy; @@ -58,10 +59,11 @@ describe(WalletInitializerService.name, () => { it("does not require a stripeCustomerId", async () => { const user = createUser({ emailVerified: true, stripeCustomerId: null as unknown as string }); - const newWallet = createUserWallet({ userId: user.id }); + const newWallet = createUserWallet({ userId: user.id, activatedAt: null }); const di = setup({ user, getOrCreateWallet: vi.fn().mockResolvedValue({ wallet: newWallet, isNew: true }), + claimActivation: vi.fn().mockResolvedValue({ ...newWallet, activatedAt: new Date() }), updateWalletById: vi.fn().mockResolvedValue(newWallet) }); const managedUserWalletService = di.resolve(ManagedUserWalletService) as MockProxy; @@ -73,82 +75,108 @@ describe(WalletInitializerService.name, () => { }); describe("initializeAndGrantTrialLimits", () => { - it("creates a new wallet and authorizes trial spending when no wallet exists", async () => { + it("derives and saves the address when the wallet is missing one", async () => { const userId = "test-user-id"; - const newWallet = createUserWallet({ userId }); + const orphanWallet = createUserWallet({ userId, address: null as unknown as string, activatedAt: null }); + const derivedAddress = "akash1derived"; + const getOrCreateWallet = vi.fn().mockResolvedValue({ wallet: orphanWallet, isNew: true }); + const updateWalletById = vi.fn().mockImplementation(async (id, patch) => ({ ...orphanWallet, ...patch })); + const claimActivation = vi.fn().mockResolvedValue({ ...orphanWallet, address: derivedAddress, activatedAt: new Date() }); + + const di = setup({ getOrCreateWallet, updateWalletById, claimActivation }); + const managedUserWalletService = di.resolve(ManagedUserWalletService) as MockProxy; + managedUserWalletService.createWallet.mockResolvedValue({ address: derivedAddress }); + managedUserWalletService.createAndAuthorizeTrialSpending.mockResolvedValue(createChainWallet({ address: derivedAddress })); + + await di.resolve(WalletInitializerService).initializeAndGrantTrialLimits(userId); + + expect(managedUserWalletService.createWallet).toHaveBeenCalledWith({ addressIndex: orphanWallet.id }); + expect(updateWalletById).toHaveBeenCalledWith(orphanWallet.id, { address: derivedAddress }, { returning: true }); + }); + + it("returns the current state without chain calls when the wallet is already activated", async () => { + const userId = "test-user-id"; + const activatedWallet = createUserWallet({ userId, activatedAt: new Date() }); + const getOrCreateWallet = vi.fn().mockResolvedValue({ wallet: activatedWallet, isNew: false }); + + const di = setup({ getOrCreateWallet }); + const managedUserWalletService = di.resolve(ManagedUserWalletService); + + const result = await di.resolve(WalletInitializerService).initializeAndGrantTrialLimits(userId); + + expect(result.address).toBe(activatedWallet.address); + expect(managedUserWalletService.createAndAuthorizeTrialSpending).not.toHaveBeenCalled(); + expect(di.resolve(DomainEventsService).publish).not.toHaveBeenCalled(); + }); + + it("claims activation, authorizes trial spending and saves the granted allowances", async () => { + const userId = "test-user-id"; + const wallet = createUserWallet({ userId, activatedAt: null }); const chainWallet = createChainWallet(); - const getOrCreateWallet = vi.fn().mockImplementation(async () => ({ wallet: newWallet, isNew: true })); - const updateWalletById = vi.fn().mockImplementation(async () => newWallet); + const getOrCreateWallet = vi.fn().mockResolvedValue({ wallet, isNew: false }); + const claimActivation = vi.fn().mockResolvedValue({ ...wallet, activatedAt: new Date() }); + const updateWalletById = vi.fn().mockImplementation(async (id, patch) => ({ ...wallet, ...patch })); - const di = setup({ - getOrCreateWallet, - updateWalletById - }); + const di = setup({ getOrCreateWallet, claimActivation, updateWalletById }); const managedUserWalletService = di.resolve(ManagedUserWalletService) as MockProxy; managedUserWalletService.createAndAuthorizeTrialSpending.mockResolvedValue(chainWallet); await di.resolve(WalletInitializerService).initializeAndGrantTrialLimits(userId); - expect(getOrCreateWallet).toHaveBeenCalledWith({ userId }); - expect(managedUserWalletService.createAndAuthorizeTrialSpending).toHaveBeenCalledWith(di.resolve(ManagedSignerService), { addressIndex: newWallet.id }); + expect(claimActivation).toHaveBeenCalledWith(wallet.id); + expect(managedUserWalletService.createAndAuthorizeTrialSpending).toHaveBeenCalledWith(di.resolve(ManagedSignerService), { addressIndex: wallet.id }); expect(updateWalletById).toHaveBeenCalledWith( - newWallet.id, + wallet.id, { - address: chainWallet.address, deploymentAllowance: chainWallet.limits.deployment, feeAllowance: chainWallet.limits.fees }, - expect.any(Object) + { returning: true } ); }); - it("does not authorizes trial spending for existing wallet", async () => { + it("throws 409 when activation is already claimed by a concurrent request", async () => { const userId = "test-user-id"; - const existingWallet = createUserWallet({ userId }); - const getOrCreateWallet = vi.fn().mockResolvedValue({ wallet: existingWallet, isNew: false }); + const wallet = createUserWallet({ userId, activatedAt: null }); + const getOrCreateWallet = vi.fn().mockResolvedValue({ wallet, isNew: false }); + const claimActivation = vi.fn().mockResolvedValue(undefined); - const di = setup({ - getOrCreateWallet - }); + const di = setup({ getOrCreateWallet, claimActivation }); const managedUserWalletService = di.resolve(ManagedUserWalletService); - await di.resolve(WalletInitializerService).initializeAndGrantTrialLimits(userId); - - expect(getOrCreateWallet).toHaveBeenCalledWith({ userId }); + await expect(di.resolve(WalletInitializerService).initializeAndGrantTrialLimits(userId)).rejects.toMatchObject({ status: 409 }); expect(managedUserWalletService.createAndAuthorizeTrialSpending).not.toHaveBeenCalled(); + expect(di.resolve(DomainEventsService).publish).not.toHaveBeenCalled(); }); - it("throws an error when cannot authorize trial spending and deletes user wallet", async () => { + it("unsets activation and keeps the wallet when authorization fails", async () => { const userId = "test-user-id"; - const newWallet = createUserWallet({ userId }); - const getOrCreateWallet = vi.fn().mockImplementation(async () => ({ wallet: newWallet, isNew: true })); - const deleteWalletById = vi.fn().mockImplementation(async () => null); + const wallet = createUserWallet({ userId, activatedAt: null }); + const getOrCreateWallet = vi.fn().mockResolvedValue({ wallet, isNew: false }); + const claimActivation = vi.fn().mockResolvedValue({ ...wallet, activatedAt: new Date() }); + const updateWalletById = vi.fn().mockImplementation(async (id, patch) => ({ ...wallet, ...patch })); + const deleteWalletById = vi.fn(); - const di = setup({ - getOrCreateWallet, - deleteWalletById - }); + const di = setup({ getOrCreateWallet, claimActivation, updateWalletById, deleteWalletById }); const managedUserWalletService = di.resolve(ManagedUserWalletService) as MockProxy; managedUserWalletService.createAndAuthorizeTrialSpending.mockRejectedValue(new Error("Failed to authorize trial")); await expect(di.resolve(WalletInitializerService).initializeAndGrantTrialLimits(userId)).rejects.toThrow("Failed to authorize trial"); - expect(managedUserWalletService.createAndAuthorizeTrialSpending).toHaveBeenCalledWith(di.resolve(ManagedSignerService), { addressIndex: newWallet.id }); - expect(deleteWalletById).toHaveBeenCalledWith(newWallet.id); + + expect(updateWalletById).toHaveBeenCalledWith(wallet.id, { activatedAt: null }); + expect(deleteWalletById).not.toHaveBeenCalled(); expect(di.resolve(DomainEventsService).publish).not.toHaveBeenCalled(); }); - it(`publishes "TrialStarted" event`, async () => { + it(`publishes "TrialStarted" event on successful activation`, async () => { const userId = "test-user-id"; - const newWallet = createUserWallet({ userId }); + const wallet = createUserWallet({ userId, activatedAt: null }); const chainWallet = createChainWallet(); - const getOrCreateWallet = vi.fn().mockImplementation(async () => ({ wallet: newWallet, isNew: true })); - const updateWalletById = vi.fn().mockImplementation(async () => newWallet); + const getOrCreateWallet = vi.fn().mockResolvedValue({ wallet, isNew: false }); + const claimActivation = vi.fn().mockResolvedValue({ ...wallet, activatedAt: new Date() }); + const updateWalletById = vi.fn().mockImplementation(async (id, patch) => ({ ...wallet, ...patch })); - const di = setup({ - userId, - getOrCreateWallet, - updateWalletById - }); + const di = setup({ userId, getOrCreateWallet, claimActivation, updateWalletById }); const managedUserWalletService = di.resolve(ManagedUserWalletService) as MockProxy; managedUserWalletService.createAndAuthorizeTrialSpending.mockResolvedValue(chainWallet); @@ -158,10 +186,48 @@ describe(WalletInitializerService.name, () => { }); }); + describe("ensureWallet", () => { + it("creates the wallet and saves a derived address when none exists", async () => { + const userId = "test-user-id"; + const bareWallet = createUserWallet({ userId, address: null as unknown as string, activatedAt: null }); + const derivedAddress = "akash1derived"; + const getOrCreateWallet = vi.fn().mockResolvedValue({ wallet: bareWallet, isNew: true }); + const updateWalletById = vi.fn().mockImplementation(async (id, patch) => ({ ...bareWallet, ...patch })); + + const di = setup({ getOrCreateWallet, updateWalletById }); + const managedUserWalletService = di.resolve(ManagedUserWalletService) as MockProxy; + managedUserWalletService.createWallet.mockResolvedValue({ address: derivedAddress }); + + const result = await di.resolve(WalletInitializerService).ensureWallet(userId); + + expect(getOrCreateWallet).toHaveBeenCalledWith({ userId }); + expect(managedUserWalletService.createWallet).toHaveBeenCalledWith({ addressIndex: bareWallet.id }); + expect(updateWalletById).toHaveBeenCalledWith(bareWallet.id, { address: derivedAddress }, { returning: true }); + expect(result.address).toBe(derivedAddress); + }); + + it("returns the existing wallet without deriving an address again", async () => { + const userId = "test-user-id"; + const existingWallet = createUserWallet({ userId }); + const getOrCreateWallet = vi.fn().mockResolvedValue({ wallet: existingWallet, isNew: false }); + const updateWalletById = vi.fn(); + + const di = setup({ getOrCreateWallet, updateWalletById }); + const managedUserWalletService = di.resolve(ManagedUserWalletService); + + const result = await di.resolve(WalletInitializerService).ensureWallet(userId); + + expect(managedUserWalletService.createWallet).not.toHaveBeenCalled(); + expect(updateWalletById).not.toHaveBeenCalled(); + expect(result).toEqual(existingWallet); + }); + }); + function setup(input?: { getOrCreateWallet?: UserWalletRepository["getOrCreate"]; updateWalletById?: UserWalletRepository["updateById"]; deleteWalletById?: UserWalletRepository["deleteById"]; + claimActivation?: UserWalletRepository["claimActivation"]; userId?: string; user?: UserOutput; isProduction?: boolean; @@ -176,6 +242,7 @@ describe(WalletInitializerService.name, () => { getOrCreate: input?.getOrCreateWallet, updateById: input?.updateWalletById, deleteById: input?.deleteWalletById ?? vi.fn(), + claimActivation: input?.claimActivation, accessibleBy() { return this as unknown as UserWalletRepository; }, diff --git a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts index 60bd707272..74b90c4dee 100644 --- a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts +++ b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts @@ -3,7 +3,7 @@ import { singleton } from "tsyringe"; import { AuthService } from "@src/auth/services/auth.service"; import { TrialStarted } from "@src/billing/events/trial-started"; -import { UserWalletPublicOutput, UserWalletRepository } from "@src/billing/repositories"; +import { UserWalletOutput, UserWalletPublicOutput, UserWalletRepository } from "@src/billing/repositories"; import { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service"; import { StripeService } from "@src/billing/services/stripe/stripe.service"; import { DomainEventsService } from "@src/core/services/domain-events/domain-events.service"; @@ -44,41 +44,52 @@ export class WalletInitializerService { } async initializeAndGrantTrialLimits(userId: string): Promise { - const { wallet, isNew } = await this.userWalletRepository.accessibleBy(this.authService.ability, "create").getOrCreate({ userId }); - let userWallet = wallet; - if (!isNew) return this.userWalletRepository.toPublic(userWallet); + const userWallet = await this.#ensureWalletVia(this.userWalletRepository.accessibleBy(this.authService.ability, "create"), userId); + if (userWallet.activatedAt) return this.userWalletRepository.toPublic(userWallet); + + const claimedWallet = await this.userWalletRepository.claimActivation(userWallet.id); + assert(claimedWallet, 409, "Trial provisioning is already in progress"); + + let activatedWallet: UserWalletOutput; try { - const wallet = await this.walletManager.createAndAuthorizeTrialSpending(this.managedSignerService, { addressIndex: userWallet.id }); - userWallet = await this.userWalletRepository.updateById( - userWallet.id, + const chainWallet = await this.walletManager.createAndAuthorizeTrialSpending(this.managedSignerService, { addressIndex: claimedWallet.id }); + activatedWallet = await this.userWalletRepository.updateById( + claimedWallet.id, { - address: wallet.address, - deploymentAllowance: wallet.limits.deployment, - feeAllowance: wallet.limits.fees + deploymentAllowance: chainWallet.limits.deployment, + feeAllowance: chainWallet.limits.fees }, { returning: true } ); } catch (error) { - await this.userWalletRepository.deleteById(userWallet.id); + await this.userWalletRepository.updateById(claimedWallet.id, { activatedAt: null }); throw error; } - const walletOutput = this.userWalletRepository.toPublic(userWallet); await this.domainEvents.publish(new TrialStarted({ userId })); - return walletOutput; + return this.userWalletRepository.toPublic(activatedWallet); + } + + /** + * Idempotently guarantees the user has a wallet row with a derived address. + * Address derivation is pure (no chain transaction), so this is safe to run on every registration. + */ + async ensureWallet(userId: string): Promise { + return this.#ensureWalletVia(this.userWalletRepository, userId); } - async initialize(userId: string) { - const { id } = await this.userWalletRepository.create({ userId }); - const wallet = await this.walletManager.createWallet({ addressIndex: id }); - return await this.userWalletRepository.updateById( - id, - { - address: wallet.address - }, - { returning: true } - ); + /** + * Concurrent calls may both derive the address, but derivation is deterministic per wallet id, + * so the two updates write the same value and the operation stays idempotent. + */ + async #ensureWalletVia(repository: UserWalletRepository, userId: string): Promise { + const { wallet } = await repository.getOrCreate({ userId }); + + if (wallet.address) return wallet; + + const { address } = await this.walletManager.createWallet({ addressIndex: wallet.id }); + return await this.userWalletRepository.updateById(wallet.id, { address }, { returning: true }); } } diff --git a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.spec.ts b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.spec.ts new file mode 100644 index 0000000000..a6d4ddd49c --- /dev/null +++ b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { AuthService } from "@src/auth/services/auth.service"; +import type { UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; +import { WalletReaderService } from "./wallet-reader.service"; + +import { createUserWallet } from "@test/seeders/user-wallet.seeder"; + +describe(WalletReaderService.name, () => { + describe("getWallets", () => { + it("returns only activated wallets", async () => { + const userId = "test-user-id"; + const activatedWallet = createUserWallet({ userId, activatedAt: new Date() }); + const nonActivatedWallet = createUserWallet({ userId, activatedAt: null }); + const { service } = setup({ wallets: [activatedWallet, nonActivatedWallet] }); + + const result = await service.getWallets({ userId }); + + expect(result).toHaveLength(1); + expect(result[0].address).toBe(activatedWallet.address); + }); + + it("returns an empty list when the user only has a non-activated wallet", async () => { + const userId = "test-user-id"; + const nonActivatedWallet = createUserWallet({ userId, activatedAt: null }); + const { service } = setup({ wallets: [nonActivatedWallet] }); + + const result = await service.getWallets({ userId }); + + expect(result).toEqual([]); + }); + }); + + function setup(input: { wallets: UserWalletOutput[] }) { + const userWalletRepository = mock({ + find: vi.fn().mockResolvedValue(input.wallets), + accessibleBy() { + return this as unknown as UserWalletRepository; + }, + toPublic: value => ({ + id: value.id, + userId: value.userId, + address: value.address, + creditAmount: value.creditAmount, + isTrialing: !!value.isTrialing, + createdAt: value.createdAt + }) + }) as unknown as UserWalletRepository; + const authService = mock({ ability: {} }); + + const service = new WalletReaderService(userWalletRepository, authService as AuthService); + + return { service, userWalletRepository, authService }; + } +}); diff --git a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts index 9c63701da8..a7b4b7d256 100644 --- a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts +++ b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts @@ -21,7 +21,7 @@ export class WalletReaderService { async getWallets(query: GetWalletOptions): Promise { const wallets = await this.userWalletRepository.accessibleBy(this.authService.ability, "read").find(query); - return wallets.map(wallet => this.userWalletRepository.toPublic(wallet)); + return wallets.filter(wallet => wallet.activatedAt).map(wallet => this.userWalletRepository.toPublic(wallet)); } async getWalletByUserId(userId: string): Promise; diff --git a/apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts b/apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts index e77f66f3b5..a888b41f83 100644 --- a/apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts +++ b/apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts @@ -23,6 +23,7 @@ export type AutoTopUpDeployment = { isWalletAutoTopUpEnabled: boolean; walletIsTrialing: boolean; walletCreatedAt: Date; + walletActivatedAt: Date | null; }; @singleton() @@ -83,7 +84,8 @@ export class DeploymentSettingRepository extends BaseRepository`coalesce(${WalletSetting.autoReloadEnabled}, false)`, walletIsTrialing: sql`coalesce(${UserWallets.isTrialing}, true)`, - walletCreatedAt: UserWallets.createdAt + walletCreatedAt: UserWallets.createdAt, + walletActivatedAt: UserWallets.activatedAt }) .from(this.table) .leftJoin(Users, eq(this.table.userId, Users.id)) diff --git a/apps/api/src/deployment/services/top-up-managed-deployments/top-up-managed-deployments.service.spec.ts b/apps/api/src/deployment/services/top-up-managed-deployments/top-up-managed-deployments.service.spec.ts index c56164ee90..eb9b81f50d 100644 --- a/apps/api/src/deployment/services/top-up-managed-deployments/top-up-managed-deployments.service.spec.ts +++ b/apps/api/src/deployment/services/top-up-managed-deployments/top-up-managed-deployments.service.spec.ts @@ -469,7 +469,8 @@ describe(TopUpManagedDeploymentsService.name, () => { expect(managedSignerService.ensureFeeGrants).toHaveBeenCalledWith({ address: deployment.address, isTrialing: deployment.walletIsTrialing, - createdAt: deployment.walletCreatedAt + createdAt: deployment.walletCreatedAt, + activatedAt: deployment.walletActivatedAt }); expect(managedSignerService.executeDerivedTx).toHaveBeenCalledTimes(1); }); diff --git a/apps/api/src/deployment/services/top-up-managed-deployments/top-up-managed-deployments.service.ts b/apps/api/src/deployment/services/top-up-managed-deployments/top-up-managed-deployments.service.ts index dea59c439e..d0ecb516da 100644 --- a/apps/api/src/deployment/services/top-up-managed-deployments/top-up-managed-deployments.service.ts +++ b/apps/api/src/deployment/services/top-up-managed-deployments/top-up-managed-deployments.service.ts @@ -123,8 +123,8 @@ export class TopUpManagedDeploymentsService { try { if (!options.dryRun) { - const { address, walletIsTrialing: isTrialing, walletCreatedAt: createdAt } = ownerInputs[0].deployment; - const feeAllowance = await this.managedSignerService.ensureFeeGrants({ address, isTrialing, createdAt }); + const { address, walletIsTrialing: isTrialing, walletCreatedAt: createdAt, walletActivatedAt: activatedAt } = ownerInputs[0].deployment; + const feeAllowance = await this.managedSignerService.ensureFeeGrants({ address, isTrialing, createdAt, activatedAt }); if (feeAllowance <= 0) { this.instrumentation.recordChainTxError({ diff --git a/apps/api/src/user/repositories/user/user.repository.integration.ts b/apps/api/src/user/repositories/user/user.repository.integration.ts index 3e0ce72077..57e59444e2 100644 --- a/apps/api/src/user/repositories/user/user.repository.integration.ts +++ b/apps/api/src/user/repositories/user/user.repository.integration.ts @@ -2,8 +2,11 @@ import { faker } from "@faker-js/faker"; import { container } from "tsyringe"; import { afterEach, describe, expect, it } from "vitest"; +import { UserWalletRepository } from "@src/billing/repositories"; import { UserRepository } from "./user.repository"; +import { createAkashAddress } from "@test/seeders/akash-address.seeder"; + describe(UserRepository.name, () => { describe("markAsActive", () => { it("updates lastActiveAt when last active longer ago than throttle", async () => { @@ -97,6 +100,27 @@ describe(UserRepository.name, () => { }); }); + describe("findTrialUsersByFingerprint", () => { + it("matches only users whose trial wallet is activated", async () => { + const { userRepository, createTestUser } = setup(); + const userWalletRepository = container.resolve(UserWalletRepository); + const fingerprint = faker.string.alphanumeric(24); + + const activatedTrialUser = await createTestUser({ lastFingerprint: fingerprint }); + const activatedWallet = await userWalletRepository.create({ userId: activatedTrialUser.id, address: createAkashAddress() }); + await userWalletRepository.claimActivation(activatedWallet.id); + + const registeredOnlyUser = await createTestUser({ lastFingerprint: fingerprint }); + await userWalletRepository.create({ userId: registeredOnlyUser.id, address: createAkashAddress() }); + + const currentUser = await createTestUser({ lastFingerprint: fingerprint }); + + const matches = await userRepository.findTrialUsersByFingerprint(fingerprint, currentUser.id); + + expect(matches).toEqual([{ id: activatedTrialUser.id }]); + }); + }); + let cleanup: () => Promise; afterEach(async () => { await cleanup?.(); @@ -128,7 +152,7 @@ describe(UserRepository.name, () => { } }; - async function createTestUser(overrides: { lastActiveAt?: Date | null; lastIp?: string } = {}) { + async function createTestUser(overrides: { lastActiveAt?: Date | null; lastIp?: string; lastFingerprint?: string } = {}) { const user = await userRepository.create({ id: faker.string.uuid(), ...newUserInput(), diff --git a/apps/api/src/user/repositories/user/user.repository.ts b/apps/api/src/user/repositories/user/user.repository.ts index bde41b2d35..24ba81e32b 100644 --- a/apps/api/src/user/repositories/user/user.repository.ts +++ b/apps/api/src/user/repositories/user/user.repository.ts @@ -1,5 +1,5 @@ import { Trace } from "@akashnetwork/instrumentation"; -import { and, eq, isNull, lt, ne, or, SQL, sql } from "drizzle-orm"; +import { and, eq, isNotNull, isNull, lt, ne, or, SQL, sql } from "drizzle-orm"; import { PgUpdateSetSource } from "drizzle-orm/pg-core"; import { singleton } from "tsyringe"; @@ -99,7 +99,7 @@ export class UserRepository extends BaseRepository { describe("registerUser", () => { it("registers a new user", async () => { @@ -337,6 +340,9 @@ describe(UserService.name, () => { const analyticsService = mock(); const logger = mock(); const auth0Service = mock(); + const walletInitializerService = mock({ + ensureWallet: vi.fn().mockResolvedValue(createUserWallet()) + }); const userRepository = container.resolve(UserRepository); const service = new UserService( userRepository, @@ -348,9 +354,10 @@ describe(UserService.name, () => { auth0Service, mock({ sendCode: vi.fn().mockResolvedValue({ codeSentAt: new Date().toISOString() }) - }) + }), + walletInitializerService ); - return { service, analyticsService, logger, auth0Service, userRepository }; + return { service, analyticsService, logger, auth0Service, userRepository, walletInitializerService }; } }); diff --git a/apps/api/src/user/services/user/user.service.spec.ts b/apps/api/src/user/services/user/user.service.spec.ts index f2a073d4fb..4b141c1943 100644 --- a/apps/api/src/user/services/user/user.service.spec.ts +++ b/apps/api/src/user/services/user/user.service.spec.ts @@ -1,11 +1,12 @@ import "@test/mocks/logger-service.mock"; import { faker } from "@faker-js/faker"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; import type { Auth0Service } from "@src/auth/services/auth0/auth0.service"; import type { EmailVerificationCodeService } from "@src/auth/services/email-verification-code/email-verification-code.service"; +import type { WalletInitializerService } from "@src/billing/services/wallet-initializer/wallet-initializer.service"; import type { LoggerService } from "@src/core/providers/logging.provider"; import type { AnalyticsService } from "@src/core/services/analytics/analytics.service"; import type { NotificationService } from "@src/notifications/services/notification/notification.service"; @@ -14,6 +15,7 @@ import type { RegisterUserInput } from "./user.service"; import { UserService } from "./user.service"; import { createUser } from "@test/seeders/user.seeder"; +import { createUserWallet } from "@test/seeders/user-wallet.seeder"; describe(UserService.name, () => { describe("registerUser", () => { @@ -80,6 +82,33 @@ describe(UserService.name, () => { expect(analyticsService.track).not.toHaveBeenCalled(); }); + + it("ensures the user has a wallet even when the user already existed", async () => { + const user = createUser({ emailVerified: true, email: "test@example.com" }); + const { service, userRepository, notificationService, walletInitializerService } = setup(); + + userRepository.upsertOnExternalIdConflict.mockResolvedValue({ user, wasInserted: false }); + notificationService.createDefaultChannel.mockResolvedValue(undefined); + + await service.registerUser(createRegisterInput({ emailVerified: true })); + + expect(walletInitializerService.ensureWallet).toHaveBeenCalledWith(user.id); + }); + + it("logs error but does not throw when wallet creation fails", async () => { + const user = createUser({ emailVerified: true, email: "test@example.com" }); + const { service, userRepository, notificationService, walletInitializerService, logger } = setup(); + const walletError = new Error("derivation failed"); + + userRepository.upsertOnExternalIdConflict.mockResolvedValue({ user, wasInserted: true }); + notificationService.createDefaultChannel.mockResolvedValue(undefined); + walletInitializerService.ensureWallet.mockRejectedValue(walletError); + + const result = await service.registerUser(createRegisterInput({ emailVerified: true })); + + expect(result.id).toBe(user.id); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "FAILED_TO_ENSURE_USER_WALLET", id: user.id, error: walletError })); + }); }); function setup() { @@ -89,10 +118,21 @@ describe(UserService.name, () => { const notificationService = mock(); const auth0Service = mock(); const emailVerificationCodeService = mock(); + const walletInitializerService = mock({ + ensureWallet: vi.fn().mockResolvedValue(createUserWallet()) + }); - const service = new UserService(userRepository, analyticsService, logger, notificationService, auth0Service, emailVerificationCodeService); - - return { service, userRepository, analyticsService, logger, notificationService, auth0Service, emailVerificationCodeService }; + const service = new UserService( + userRepository, + analyticsService, + logger, + notificationService, + auth0Service, + emailVerificationCodeService, + walletInitializerService + ); + + return { service, userRepository, analyticsService, logger, notificationService, auth0Service, emailVerificationCodeService, walletInitializerService }; } function createRegisterInput(overrides: Partial = {}): RegisterUserInput { diff --git a/apps/api/src/user/services/user/user.service.ts b/apps/api/src/user/services/user/user.service.ts index 646ff59761..138e690e2a 100644 --- a/apps/api/src/user/services/user/user.service.ts +++ b/apps/api/src/user/services/user/user.service.ts @@ -4,6 +4,7 @@ import { singleton } from "tsyringe"; import { Auth0Service } from "@src/auth/services/auth0/auth0.service"; import { EmailVerificationCodeService } from "@src/auth/services/email-verification-code/email-verification-code.service"; +import { WalletInitializerService } from "@src/billing/services/wallet-initializer/wallet-initializer.service"; import { LoggerService } from "@src/core/providers/logging.provider"; import { getPostgresError, isUniqueViolation } from "@src/core/repositories/base.repository"; import { AnalyticsService } from "@src/core/services/analytics/analytics.service"; @@ -18,7 +19,8 @@ export class UserService { private readonly logger: LoggerService, private readonly notificationService: NotificationService, private readonly auth0: Auth0Service, - private readonly emailVerificationCodeService: EmailVerificationCodeService + private readonly emailVerificationCodeService: EmailVerificationCodeService, + private readonly walletInitializer: WalletInitializerService ) {} async registerUser(data: RegisterUserInput): Promise<{ @@ -59,6 +61,10 @@ export class UserService { this.analyticsService.track(user.id, "account_created", { category: "user" }); } + await this.walletInitializer.ensureWallet(user.id).catch(error => { + this.logger.error({ event: "FAILED_TO_ENSURE_USER_WALLET", id: user.id, error }); + }); + const result = await this.notificationService.createDefaultChannel(user).catch(error => ({ error })); if (result?.error) { diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index d4ce982b62..a08ff4ba15 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -13471,6 +13471,9 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` }, "description": "Trial started successfully and wallet created", }, + "409": { + "description": "Trial provisioning is already in progress for this user; retry shortly", + }, }, "security": [], "summary": "Start a trial period for a user", diff --git a/apps/api/test/functional/wallets-refill.spec.ts b/apps/api/test/functional/wallets-refill.spec.ts index e52b02e506..50cf3e4794 100644 --- a/apps/api/test/functional/wallets-refill.spec.ts +++ b/apps/api/test/functional/wallets-refill.spec.ts @@ -31,17 +31,19 @@ describe("Wallets Refill", () => { }); describe("console refill-wallets", () => { - it("refills wallets low on fee allowance", async () => { - const wallets = await setup(); + it("refills activated wallets low on fee allowance and skips non-activated ones", async () => { + const { activatedWallets, nonActivatedWallet } = await setup(); await walletController.refillWallets(); await Promise.all( - wallets.map(async wallet => { + activatedWallets.map(async wallet => { const walletRecord = await userWalletRepository.findById(wallet.id); expect(walletRecord?.feeAllowance).toBe(config.FEE_ALLOWANCE_REFILL_AMOUNT); }) ); + const nonActivatedRecord = await userWalletRepository.findById(nonActivatedWallet.id); + expect(nonActivatedRecord?.feeAllowance).toBe(config.FEE_ALLOWANCE_REFILL_THRESHOLD); }); }); @@ -59,22 +61,32 @@ describe("Wallets Refill", () => { .reply(200, createDeploymentGrantResponseSeed({ amount: String(config.TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT) })); const NUMBER_OF_WALLETS = 5; - return Promise.all( - Array.from({ length: NUMBER_OF_WALLETS }).map(async (_, index) => { - const user = await userRepository.create({}); - const address = createAkashAddress(); - const [wallet] = await db - .insert(userWalletsTable) - .values({ - userId: user.id, - address, - isTrialing: index === NUMBER_OF_WALLETS - 1, - deploymentAllowance: String(config.TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT), - feeAllowance: String(config.FEE_ALLOWANCE_REFILL_THRESHOLD) - }) - .returning(); - return wallet; - }) + const activatedWallets = await Promise.all( + Array.from({ length: NUMBER_OF_WALLETS }).map(async (_, index) => + createWallet({ + isTrialing: index === NUMBER_OF_WALLETS - 1, + activatedAt: new Date() + }) + ) ); + const nonActivatedWallet = await createWallet({ isTrialing: true, activatedAt: null }); + + return { activatedWallets, nonActivatedWallet }; + + async function createWallet(input: { isTrialing: boolean; activatedAt: Date | null }) { + const user = await userRepository.create({}); + const [wallet] = await db + .insert(userWalletsTable) + .values({ + userId: user.id, + address: createAkashAddress(), + isTrialing: input.isTrialing, + activatedAt: input.activatedAt, + deploymentAllowance: String(config.TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT), + feeAllowance: String(config.FEE_ALLOWANCE_REFILL_THRESHOLD) + }) + .returning(); + return wallet; + } } }); diff --git a/apps/api/test/seeders/auto-top-up-deployment.seeder.ts b/apps/api/test/seeders/auto-top-up-deployment.seeder.ts index 0103491b37..3ee18ac2e8 100644 --- a/apps/api/test/seeders/auto-top-up-deployment.seeder.ts +++ b/apps/api/test/seeders/auto-top-up-deployment.seeder.ts @@ -12,6 +12,7 @@ export function createAutoTopUpDeployment(overrides: Partial = {}): UserWalletOutput { return { id, @@ -22,6 +23,7 @@ export function createUserWallet({ isTrialing, creditAmount: deploymentAllowance, createdAt, - updatedAt + updatedAt, + activatedAt }; } From 51ba823e2e823edc83c795fe80fdee1b599fd1ca Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:49:53 +0100 Subject: [PATCH 2/4] fix(billing): restore no-cycle lint suppression on user-wallet schema import --- .../src/billing/model-schemas/user-wallet/user-wallet.schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts b/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts index 7acbb989a1..aff1fbf51d 100644 --- a/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts +++ b/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts @@ -1,5 +1,6 @@ import { boolean, numeric, pgTable, serial, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; +// eslint-disable-next-line import-x/no-cycle import { Users } from "@src/user/model-schemas/user/user.schema"; export const UserWallets = pgTable("user_wallets", { From d6697217558ea9ab63790a09cf67a47e73d2f5cd Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:02:15 +0100 Subject: [PATCH 3/4] fix(billing): address review on wallet-per-account activation - Backfill activated_at (timestamptz) by interpreting created_at (timestamp without time zone) as UTC, so migration is independent of session TimeZone. - claimActivation enforces ability at the mutation site via whereAccessibleBy, matching updateById/deleteById (passes through when no ability is set). - RefillService.refillWalletFees delegates to ManagedUserWalletService .refillWalletFees, removing the duplicated trial-window/expiration calc. --- apps/api/drizzle/0033_long_solo.sql | 2 +- .../user-wallet/user-wallet.repository.ts | 2 +- .../billing/services/refill/refill.service.ts | 16 +--------------- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/apps/api/drizzle/0033_long_solo.sql b/apps/api/drizzle/0033_long_solo.sql index 208ce42797..2119e15dea 100644 --- a/apps/api/drizzle/0033_long_solo.sql +++ b/apps/api/drizzle/0033_long_solo.sql @@ -1,2 +1,2 @@ ALTER TABLE "user_wallets" ADD COLUMN "activated_at" timestamp with time zone;--> statement-breakpoint -UPDATE "user_wallets" SET "activated_at" = "created_at" WHERE "address" IS NOT NULL; \ No newline at end of file +UPDATE "user_wallets" SET "activated_at" = "created_at" AT TIME ZONE 'UTC' WHERE "address" IS NOT NULL; \ No newline at end of file diff --git a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts index dfbdd34656..dc24fe1e16 100644 --- a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts +++ b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts @@ -86,7 +86,7 @@ export class UserWalletRepository extends BaseRepository trialWindowStart; - - const expiration = isInTrialWindow && trialStartedAt ? addDays(trialStartedAt, this.config.TRIAL_ALLOWANCE_EXPIRATION_DAYS) : undefined; - - await this.managedUserWalletService.authorizeSpending(this.managedSignerService, { - address: userWallet.address!, - limits: { - fees: this.config.FEE_ALLOWANCE_REFILL_AMOUNT - }, - expiration - }); + await this.managedUserWalletService.refillWalletFees(this.managedSignerService, userWallet); await this.balancesService.refreshUserWalletLimits(userWallet); } From 55de7f534c84663fabd2c0b74f6838a5ddde51e3 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:32:24 +0100 Subject: [PATCH 4/4] refactor(billing): describe wallet activation helper by behavior, not change --- apps/api/src/billing/services/refill/refill.service.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/api/src/billing/services/refill/refill.service.ts b/apps/api/src/billing/services/refill/refill.service.ts index e85cc69d25..628327bafd 100644 --- a/apps/api/src/billing/services/refill/refill.service.ts +++ b/apps/api/src/billing/services/refill/refill.service.ts @@ -63,7 +63,7 @@ export class RefillService { * @param options.payment - Payment context attached to the `balance_top_up` analytics event */ async topUpWallet(amountUsd: number, userId: UserWalletOutput["userId"], options: { endTrial?: boolean; payment?: PaymentAnalyticsContext } = {}) { - const userWallet = await this.getOrCreateUserWallet(userId); + const userWallet = await this.ensureActivatedWallet(userId); const currentLimit = await this.balancesService.retrieveDeploymentLimit(userWallet); const nextLimit = currentLimit + amountUsd * 10000; @@ -124,10 +124,11 @@ export class RefillService { } /** - * Funding a wallet with real money activates it even when the user never started a trial, - * so `claimActivation` is attempted on every top-up and silently no-ops for already-activated wallets. + * Returns the user's wallet, creating and activating it as needed — + * funding with real money must activate a wallet even when the user never started a trial. + * The activation claim no-ops for already-activated wallets. */ - private async getOrCreateUserWallet(userId: UserWalletOutput["userId"]) { + private async ensureActivatedWallet(userId: UserWalletOutput["userId"]) { const userWallet = await this.walletInitializerService.ensureWallet(userId); return (await this.userWalletRepository.claimActivation(userWallet.id)) ?? userWallet;