From 7a3c76676bc9c0951217ada7f5c12c10eea23d18 Mon Sep 17 00:00:00 2001 From: Iaroslav Gryshaiev Date: Tue, 25 Nov 2025 11:20:10 +0100 Subject: [PATCH 1/9] feat(billing): adds balance check job handler refs #1779 --- apps/api/drizzle/0023_sad_adam_warlock.sql | 1 + apps/api/drizzle/0024_thankful_stick.sql | 1 + apps/api/drizzle/meta/0023_snapshot.json | 791 +++++++++++++++++ apps/api/drizzle/meta/0024_snapshot.json | 798 ++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 14 + apps/api/src/auth/services/auth.service.ts | 19 + .../controllers/stripe/stripe.controller.ts | 31 +- .../src/billing/http-schemas/stripe.schema.ts | 12 +- .../src/billing/http-schemas/wallet.schema.ts | 12 - .../payment-method/payment-method.schema.ts | 1 + .../user-wallet/user-wallet.schema.ts | 3 +- .../wallet-setting/wallet-setting.schema.ts | 13 +- .../payment-method.repository.ts | 67 +- .../user-wallet/user-wallet.repository.ts | 10 +- .../wallet-settings.repository.ts | 27 +- .../stripe-payment-methods.router.ts | 32 + .../services/balances/balances.service.ts | 24 +- .../services/paying-user/paying-user.ts | 14 + .../billing/services/refill/refill.service.ts | 3 +- .../stripe-webhook/stripe-webhook.service.ts | 22 +- .../billing/services/stripe/stripe.service.ts | 107 ++- ...allet-balance-reload-check.handler.spec.ts | 306 +++++++ .../wallet-balance-reload-check.handler.ts | 223 ++++- .../wallet-initializer.service.ts | 4 +- .../wallet-settings.service.spec.ts | 31 +- .../wallet-settings.service.ts | 103 ++- .../src/core/repositories/base.repository.ts | 12 +- .../job-queue/job-queue.service.spec.ts | 75 +- .../services/job-queue/job-queue.service.ts | 21 +- apps/api/src/core/services/tx/tx.service.ts | 12 +- apps/api/src/core/types/require.type.ts | 3 + .../__snapshots__/docs.spec.ts.snap | 46 + apps/api/test/seeders/balance.seeder.ts | 17 + .../seeders/database-payment-method.seeder.ts | 15 +- .../api/test/seeders/payment-method.seeder.ts | 7 + .../http-sdk/src/stripe/stripe.service.ts | 5 - 36 files changed, 2704 insertions(+), 178 deletions(-) create mode 100644 apps/api/drizzle/0023_sad_adam_warlock.sql create mode 100644 apps/api/drizzle/0024_thankful_stick.sql create mode 100644 apps/api/drizzle/meta/0023_snapshot.json create mode 100644 apps/api/drizzle/meta/0024_snapshot.json create mode 100644 apps/api/src/billing/services/paying-user/paying-user.ts create mode 100644 apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.spec.ts create mode 100644 apps/api/src/core/types/require.type.ts diff --git a/apps/api/drizzle/0023_sad_adam_warlock.sql b/apps/api/drizzle/0023_sad_adam_warlock.sql new file mode 100644 index 0000000000..d32a87eb83 --- /dev/null +++ b/apps/api/drizzle/0023_sad_adam_warlock.sql @@ -0,0 +1 @@ +ALTER TABLE "user_wallets" ALTER COLUMN "user_id" SET NOT NULL; \ No newline at end of file diff --git a/apps/api/drizzle/0024_thankful_stick.sql b/apps/api/drizzle/0024_thankful_stick.sql new file mode 100644 index 0000000000..d7540626d5 --- /dev/null +++ b/apps/api/drizzle/0024_thankful_stick.sql @@ -0,0 +1 @@ +ALTER TABLE "payment_methods" ADD COLUMN "is_default" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0023_snapshot.json b/apps/api/drizzle/meta/0023_snapshot.json new file mode 100644 index 0000000000..9e28224fc0 --- /dev/null +++ b/apps/api/drizzle/meta/0023_snapshot.json @@ -0,0 +1,791 @@ +{ + "id": "c99448f1-eb44-4ee4-b25b-132521e40656", + "prevId": "f58c7d88-f343-43f6-a7eb-ab36c3ad191c", + "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 + }, + "is_old_wallet": { + "name": "is_old_wallet", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "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": {}, + "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" + ] + } + } + }, + "public.checkout_sessions": { + "name": "checkout_sessions", + "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 + }, + "session_id": { + "name": "session_id", + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "checkout_sessions_user_id_userSetting_id_fk": { + "name": "checkout_sessions_user_id_userSetting_id_fk", + "tableFrom": "checkout_sessions", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "checkout_sessions_session_id_unique": { + "name": "checkout_sessions_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "session_id" + ] + } + } + }, + "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 + }, + "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_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": {} + }, + "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 + }, + "auto_reload_threshold": { + "name": "auto_reload_threshold", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": false + }, + "auto_reload_amount": { + "name": "auto_reload_amount", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": false + }, + "auto_reload_job_id": { + "name": "auto_reload_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": 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" + ] + } + } + }, + "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" + ] + } + } + }, + "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" + ] + } + } + }, + "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" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0024_snapshot.json b/apps/api/drizzle/meta/0024_snapshot.json new file mode 100644 index 0000000000..e9248074f1 --- /dev/null +++ b/apps/api/drizzle/meta/0024_snapshot.json @@ -0,0 +1,798 @@ +{ + "id": "e4c9b4cc-05c4-4c5c-ada4-e1b8bd093dfb", + "prevId": "c99448f1-eb44-4ee4-b25b-132521e40656", + "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 + }, + "is_old_wallet": { + "name": "is_old_wallet", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "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": {}, + "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" + ] + } + } + }, + "public.checkout_sessions": { + "name": "checkout_sessions", + "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 + }, + "session_id": { + "name": "session_id", + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "checkout_sessions_user_id_userSetting_id_fk": { + "name": "checkout_sessions_user_id_userSetting_id_fk", + "tableFrom": "checkout_sessions", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "checkout_sessions_session_id_unique": { + "name": "checkout_sessions_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "session_id" + ] + } + } + }, + "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_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": {} + }, + "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 + }, + "auto_reload_threshold": { + "name": "auto_reload_threshold", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": false + }, + "auto_reload_amount": { + "name": "auto_reload_amount", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": false + }, + "auto_reload_job_id": { + "name": "auto_reload_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": 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" + ] + } + } + }, + "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" + ] + } + } + }, + "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" + ] + } + } + }, + "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" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_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 dffaa79e53..1627de00d5 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -162,6 +162,20 @@ "when": 1764065637598, "tag": "0022_lazy_kabuki", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1764065989947, + "tag": "0023_sad_adam_warlock", + "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1764152714000, + "tag": "0024_thankful_stick", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/auth/services/auth.service.ts b/apps/api/src/auth/services/auth.service.ts index 96143e13ce..6d44d8f837 100644 --- a/apps/api/src/auth/services/auth.service.ts +++ b/apps/api/src/auth/services/auth.service.ts @@ -2,6 +2,7 @@ import { Ability, subject } from "@casl/ability"; import assert from "http-assert"; import { container, Lifecycle, scoped } from "tsyringe"; +import { assertIsPayingUser, isPayingUser, PayingUser } from "@src/billing/services/paying-user/paying-user"; import { ExecutionContextService } from "@src/core/services/execution-context/execution-context.service"; import { UserOutput } from "@src/user/repositories"; @@ -22,6 +23,24 @@ export class AuthService { return user; } + getCurrentPayingUser(): PayingUser; + getCurrentPayingUser(options: { strict: false }): PayingUser | undefined; + getCurrentPayingUser(options: { strict: true }): PayingUser; + getCurrentPayingUser(options = { strict: true }): PayingUser | undefined { + const user = this.executionContextService.get("CURRENT_USER"); + + assert(user, 401, "User not found"); + + if (options.strict) { + assertIsPayingUser(user); + return user; + } else if (isPayingUser(user)) { + return user; + } + + return undefined; + } + set ability(ability: Ability) { this.executionContextService.set("ABILITY", ability); } diff --git a/apps/api/src/billing/controllers/stripe/stripe.controller.ts b/apps/api/src/billing/controllers/stripe/stripe.controller.ts index bb39b7a1eb..c4875c0588 100644 --- a/apps/api/src/billing/controllers/stripe/stripe.controller.ts +++ b/apps/api/src/billing/controllers/stripe/stripe.controller.ts @@ -5,7 +5,7 @@ import type { infer as ZodInfer } from "zod"; import { AuthService, Protected } from "@src/auth/services/auth.service"; import type { StripePricesOutputResponse } from "@src/billing"; -import { CustomerTransactionsCsvExportQuerySchema } from "@src/billing/http-schemas/stripe.schema"; +import { CustomerTransactionsCsvExportQuerySchema, PaymentMethodMarkAsDefaultInput, PaymentMethodsResponse } from "@src/billing/http-schemas/stripe.schema"; import { ApplyCouponRequest, ConfirmPaymentRequest, @@ -42,30 +42,35 @@ export class StripeController { return { data: { clientSecret: setupIntent.client_secret } }; } + @Protected([{ action: "update", subject: "StripePayment" }]) + async markAsDefault(input: PaymentMethodMarkAsDefaultInput): Promise { + const { ability } = this.authService; + const currentUser = this.authService.getCurrentPayingUser(); + + await this.stripe.markPaymentMethodAsDefault(input.data.id, currentUser, ability); + } + @Protected([{ action: "read", subject: "StripePayment" }]) - async getPaymentMethods(): Promise<{ data: Stripe.PaymentMethod[] }> { - const { currentUser } = this.authService; + async getPaymentMethods(): Promise { + const currentUser = this.authService.getCurrentPayingUser({ strict: false }); - if (!currentUser.stripeCustomerId) { - return { data: [] }; + if (currentUser) { + const paymentMethods = await this.stripe.getPaymentMethods(currentUser.id, currentUser.stripeCustomerId); + return { data: paymentMethods }; } - const paymentMethods = await this.stripe.getPaymentMethods(currentUser.id, currentUser.stripeCustomerId); - return { data: paymentMethods }; + return { data: [] }; } @Semaphore() @Protected([{ action: "create", subject: "StripePayment" }]) async confirmPayment(params: ConfirmPaymentRequest["data"]): Promise { - const { currentUser } = this.authService; + const currentUser = this.authService.getCurrentPayingUser({ strict: false }); - assert(currentUser.stripeCustomerId, 500, "Payment account not properly configured. Please contact support."); + assert(currentUser, 500, "Payment account not properly configured. Please contact support."); try { - // Verify payment method ownership - const paymentMethod = await this.stripe.paymentMethods.retrieve(params.paymentMethodId); - const customerId = typeof paymentMethod.customer === "string" ? paymentMethod.customer : paymentMethod.customer?.id; - assert(customerId === currentUser.stripeCustomerId, 403, "Payment method does not belong to the user"); + assert(await this.stripe.hasPaymentMethod(params.paymentMethodId, currentUser), 403, "Payment method does not belong to the user"); const result = await this.stripe.createPaymentIntent({ customer: currentUser.stripeCustomerId, diff --git a/apps/api/src/billing/http-schemas/stripe.schema.ts b/apps/api/src/billing/http-schemas/stripe.schema.ts index 39f5a89b1f..21518d60a0 100644 --- a/apps/api/src/billing/http-schemas/stripe.schema.ts +++ b/apps/api/src/billing/http-schemas/stripe.schema.ts @@ -7,6 +7,12 @@ export const SetupIntentResponseSchema = z.object({ }) }); +export const PaymentMethodMarkAsDefaultInputSchema = z.object({ + data: z.object({ + id: z.string() + }) +}); + export const PaymentMethodSchema = z.object({ type: z.string(), validated: z.boolean().optional(), @@ -227,6 +233,7 @@ export const RemovePaymentMethodParamsSchema = z.object({ }); export type SetupIntentResponse = z.infer; +export type PaymentMethodMarkAsDefaultInput = z.infer; export type PaymentMethod = z.infer; export type PaymentMethodsResponse = z.infer; export type ConfirmPaymentRequest = z.infer; @@ -235,10 +242,5 @@ export type PaymentMethodValidationResult = z.infer; export type ApplyCouponRequest = z.infer; export type Coupon = z.infer; -export type ApplyCouponResponse = z.infer; export type Transaction = z.infer; -export type CustomerTransactionsResponse = z.infer; -export type CustomerTransactionsQuery = z.infer; -export type ValidatePaymentMethodRequest = z.infer; -export type ValidatePaymentMethodResponse = z.infer; export type UpdateCustomerOrganizationRequest = z.infer; diff --git a/apps/api/src/billing/http-schemas/wallet.schema.ts b/apps/api/src/billing/http-schemas/wallet.schema.ts index 23c90264ba..d40e0e276f 100644 --- a/apps/api/src/billing/http-schemas/wallet.schema.ts +++ b/apps/api/src/billing/http-schemas/wallet.schema.ts @@ -9,13 +9,6 @@ const WalletOutputSchema = z.object({ createdAt: z.coerce.date().nullable().openapi({}) }); -const ThreeDSecureAuthSchema = z.object({ - requires3DS: z.boolean(), - clientSecret: z.string(), - paymentIntentId: z.string(), - paymentMethodId: z.string() -}); - const WalletWithOptional3DSSchema = WalletOutputSchema.extend({ requires3DS: z.boolean().optional(), clientSecret: z.string().nullable().optional(), @@ -71,12 +64,7 @@ export const UpdateWalletSettingsRequestSchema = z.object({ data: WalletSettingsSchema.partial() }); -export type WalletOutput = z.infer; -export type ThreeDSecureAuth = z.infer; -export type WalletWithOptional3DS = z.infer; export type WalletOutputResponse = z.infer; -export type WalletResponseNo3DSOutput = z.infer; -export type WalletResponse3DSOutput = z.infer; export type WalletListOutputResponse = z.infer; export type StartTrialRequestInput = z.infer; export type WalletSettingsResponse = z.infer; diff --git a/apps/api/src/billing/model-schemas/payment-method/payment-method.schema.ts b/apps/api/src/billing/model-schemas/payment-method/payment-method.schema.ts index f5412f46ab..59898404f6 100644 --- a/apps/api/src/billing/model-schemas/payment-method/payment-method.schema.ts +++ b/apps/api/src/billing/model-schemas/payment-method/payment-method.schema.ts @@ -16,6 +16,7 @@ export const PaymentMethods = pgTable( fingerprint: varchar("fingerprint", { length: 255 }).notNull(), paymentMethodId: varchar("payment_method_id", { length: 255 }).notNull(), isValidated: boolean("is_validated").default(false).notNull(), + isDefault: boolean("is_default").default(false).notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull() }, 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 4a8f07b7b3..1c48054a6e 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 @@ -6,7 +6,8 @@ export const UserWallets = pgTable("user_wallets", { id: serial("id").primaryKey(), userId: uuid("user_id") .references(() => Users.id, { onDelete: "cascade" }) - .unique(), + .unique() + .notNull(), address: varchar("address").unique(), deploymentAllowance: allowance("deployment_allowance"), feeAllowance: allowance("fee_allowance"), diff --git a/apps/api/src/billing/model-schemas/wallet-setting/wallet-setting.schema.ts b/apps/api/src/billing/model-schemas/wallet-setting/wallet-setting.schema.ts index db510e63c1..378a0cdba9 100644 --- a/apps/api/src/billing/model-schemas/wallet-setting/wallet-setting.schema.ts +++ b/apps/api/src/billing/model-schemas/wallet-setting/wallet-setting.schema.ts @@ -1,4 +1,4 @@ -import { sql } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { boolean, index, integer, numeric, pgTable, timestamp, unique, uuid } from "drizzle-orm/pg-core"; import { UserWallets } from "@src/billing/model-schemas/user-wallet/user-wallet.schema"; @@ -35,3 +35,14 @@ export const WalletSetting = pgTable( userIdIdx: index("wallet_settings_user_id_idx").on(table.userId) }) ); + +export const WalletSettingRelations = relations(WalletSetting, ({ one }) => ({ + user: one(Users, { + fields: [WalletSetting.userId], + references: [Users.id] + }), + wallet: one(UserWallets, { + fields: [WalletSetting.walletId], + references: [UserWallets.id] + }) +})); diff --git a/apps/api/src/billing/repositories/payment-method/payment-method.repository.ts b/apps/api/src/billing/repositories/payment-method/payment-method.repository.ts index 005c2e0332..3d71c85da3 100644 --- a/apps/api/src/billing/repositories/payment-method/payment-method.repository.ts +++ b/apps/api/src/billing/repositories/payment-method/payment-method.repository.ts @@ -1,13 +1,14 @@ -import { and, eq, inArray, ne } from "drizzle-orm"; +import { and, count, eq, inArray, ne, sql } from "drizzle-orm"; import { singleton } from "tsyringe"; +import { uuidv4 } from "unleash-client/lib/uuidv4"; import { type ApiPgDatabase, type ApiPgTables, InjectPg, InjectPgTable } from "@src/core/providers"; import { type AbilityParams, BaseRepository } from "@src/core/repositories/base.repository"; -import { TxService } from "@src/core/services"; +import { type ApiTransaction, TxService } from "@src/core/services"; type Table = ApiPgTables["PaymentMethods"]; -type PaymentMethodInput = ApiPgTables["PaymentMethods"]["$inferInsert"]; -type PaymentMethodOutput = ApiPgTables["PaymentMethods"]["$inferSelect"]; +export type PaymentMethodInput = ApiPgTables["PaymentMethods"]["$inferInsert"]; +export type PaymentMethodOutput = ApiPgTables["PaymentMethods"]["$inferSelect"]; @singleton() export class PaymentMethodRepository extends BaseRepository { @@ -66,6 +67,64 @@ export class PaymentMethodRepository extends BaseRepository { + const [output] = await tx + .update(this.table) + .set({ + isDefault: true, + updatedAt: sql`now()` + }) + .where(this.queryToWhere({ paymentMethodId })) + .returning(); + + if (output) { + await this.#unmarkAsDefaultExcluding(output.id, tx); + + return this.toOutput(output); + } + }); + } + + async createAsDefault(input: Omit) { + return this.ensureTransaction(async tx => { + const id = uuidv4(); + const [output] = await Promise.all([ + this.create({ + ...input, + isDefault: true, + id + }), + this.#unmarkAsDefaultExcluding(id, tx) + ]); + + return this.toOutput(output); + }); + } + + async #unmarkAsDefaultExcluding(excludedId: PaymentMethodOutput["id"], tx: ApiTransaction) { + await tx + .update(this.table) + .set({ + isDefault: false, + updatedAt: sql`now()` + }) + .where(and(this.queryToWhere({ isDefault: true }), ne(this.table.id, excludedId))); + } + async deleteByFingerprint(fingerprint: string, paymentMethodId: string, userId: string) { return await this.deleteBy({ fingerprint, paymentMethodId, userId }); } 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 4747066e27..726e372afc 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 @@ -6,7 +6,8 @@ import { type ApiPgDatabase, type ApiPgTables, InjectPg, InjectPgTable } from "@ import { type AbilityParams, BaseRepository } from "@src/core/repositories/base.repository"; import { TxService } from "@src/core/services"; -export type DbUserWalletInput = Partial; +export type DbCreateUserWalletInput = ApiPgTables["UserWallets"]["$inferInsert"]; +export type DbUserWalletInput = Partial; export type UserWalletInput = Partial< Omit & { deploymentAllowance: number; @@ -20,11 +21,6 @@ export type UserWalletOutput = Omit) { + async create(input: Pick) { const value = { userId: input.userId, address: input.address, diff --git a/apps/api/src/billing/repositories/wallet-settings/wallet-settings.repository.ts b/apps/api/src/billing/repositories/wallet-settings/wallet-settings.repository.ts index 3cdf443a7c..249e36c867 100644 --- a/apps/api/src/billing/repositories/wallet-settings/wallet-settings.repository.ts +++ b/apps/api/src/billing/repositories/wallet-settings/wallet-settings.repository.ts @@ -35,14 +35,39 @@ export class WalletSettingRepository extends BaseRepository { const walletSetting = await this.cursor.query.WalletSetting.findFirst({ where: this.whereAccessibleBy(eq(this.table.userId, userId)) }); + if (!walletSetting) return undefined; + return this.toOutput(walletSetting); } + async findInternalByUserIdWithRelations(userId: WalletSettingOutput["userId"]) { + const walletSetting = await this.cursor.query.WalletSetting.findFirst({ + where: this.whereAccessibleBy(eq(this.table.userId, userId)), + with: { + wallet: { + columns: { + address: true, + isOldWallet: true + } + }, + user: true + } + }); + + if (!walletSetting) return undefined; + + return { + ...walletSetting, + autoReloadThreshold: walletSetting.autoReloadThreshold === null ? undefined : parseFloat(walletSetting.autoReloadThreshold), + autoReloadAmount: walletSetting.autoReloadAmount === null ? undefined : parseFloat(walletSetting.autoReloadAmount) + }; + } + protected toOutput(dbOutput: Partial): WalletSettingOutput { const output = dbOutput as DbWalletSettingOutput; return { diff --git a/apps/api/src/billing/routes/stripe-payment-methods/stripe-payment-methods.router.ts b/apps/api/src/billing/routes/stripe-payment-methods/stripe-payment-methods.router.ts index 072b671a1d..c27c9fa8a9 100644 --- a/apps/api/src/billing/routes/stripe-payment-methods/stripe-payment-methods.router.ts +++ b/apps/api/src/billing/routes/stripe-payment-methods/stripe-payment-methods.router.ts @@ -2,6 +2,7 @@ import { container } from "tsyringe"; import { StripeController } from "@src/billing/controllers/stripe/stripe.controller"; import { + PaymentMethodMarkAsDefaultInputSchema, PaymentMethodsResponseSchema, RemovePaymentMethodParamsSchema, SetupIntentResponseSchema, @@ -34,11 +35,39 @@ const setupIntentRoute = createRoute({ } } }); + stripePaymentMethodsRouter.openapi(setupIntentRoute, async function createSetupIntent(c) { const response = await container.resolve(StripeController).createSetupIntent(); return c.json(response, 200); }); +const updateRoute = createRoute({ + method: "post", + path: `/v1/stripe/payment-methods/default`, + summary: "Marks a payment method as the default.", + tags: ["Payment"], + security: SECURITY_BEARER_OR_API_KEY, + request: { + body: { + content: { + "application/json": { + schema: PaymentMethodMarkAsDefaultInputSchema + } + } + } + }, + responses: { + 200: { + description: "Payment method is marked as the default successfully." + } + } +}); + +stripePaymentMethodsRouter.openapi(updateRoute, async function markAsDefault(c) { + await container.resolve(StripeController).markAsDefault(c.req.valid("json")); + return c.json(undefined, 200); +}); + const paymentMethodsRoute = createRoute({ method: "get", path: "/v1/stripe/payment-methods", @@ -59,6 +88,7 @@ const paymentMethodsRoute = createRoute({ } } }); + stripePaymentMethodsRouter.openapi(paymentMethodsRoute, async function getPaymentMethods(c) { const response = await container.resolve(StripeController).getPaymentMethods(); return c.json(response, 200); @@ -80,6 +110,7 @@ const removePaymentMethodRoute = createRoute({ } } }); + stripePaymentMethodsRouter.openapi(removePaymentMethodRoute, async function removePaymentMethod(c) { const { paymentMethodId } = c.req.valid("param"); await container.resolve(StripeController).removePaymentMethod(paymentMethodId); @@ -114,6 +145,7 @@ const validatePaymentMethodRoute = createRoute({ } } }); + stripePaymentMethodsRouter.openapi(validatePaymentMethodRoute, async function validatePaymentMethod(c) { return c.json(await container.resolve(StripeController).validatePaymentMethodAfter3DS(c.req.valid("json")), 200); }); diff --git a/apps/api/src/billing/services/balances/balances.service.ts b/apps/api/src/billing/services/balances/balances.service.ts index 3c578bf551..8a76280a7a 100644 --- a/apps/api/src/billing/services/balances/balances.service.ts +++ b/apps/api/src/billing/services/balances/balances.service.ts @@ -6,6 +6,7 @@ import { type BillingConfig, InjectBillingConfig } from "@src/billing/providers" import { type UserWalletInput, type UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; import { TxManagerService } from "@src/billing/services/tx-manager/tx-manager.service"; import { Memoize } from "@src/caching/helpers"; +import { StatsService } from "@src/dashboard/services/stats/stats.service"; import { averageBlockTime } from "@src/utils/constants"; @singleton() @@ -15,7 +16,8 @@ export class BalancesService { private readonly userWalletRepository: UserWalletRepository, private txManagerService: TxManagerService, private readonly authzHttpService: AuthzHttpService, - private readonly deploymentHttpService: DeploymentHttpService + private readonly deploymentHttpService: DeploymentHttpService, + private readonly statsService: StatsService ) {} async refreshUserWalletLimits(userWallet: UserWalletOutput, options?: { endTrial: boolean }): Promise { @@ -106,4 +108,24 @@ export class BalancesService { } }; } + + @Memoize({ ttlInSeconds: averageBlockTime }) + async getFullBalanceInFiat(address: string, isOldWallet: boolean = false): Promise { + const coin = this.config.DEPLOYMENT_GRANT_DENOM === "uakt" ? "akash-network" : "usd-coin"; + const [fullBalance, stats] = await Promise.all([this.getFullBalance(address, isOldWallet), this.statsService.getMarketData(coin)]); + + const balance = this.#toFiatAmount(fullBalance.data.balance * stats.price); + const deployments = this.#toFiatAmount(fullBalance.data.deployments * stats.price); + const total = this.#formatFiatAmount(balance + deployments); + + return { balance, deployments, total }; + } + + #toFiatAmount(uTokenAmount: number) { + return this.#formatFiatAmount(uTokenAmount / 1_000_000); + } + + #formatFiatAmount(amount: number) { + return parseFloat(amount.toFixed(2)); + } } diff --git a/apps/api/src/billing/services/paying-user/paying-user.ts b/apps/api/src/billing/services/paying-user/paying-user.ts new file mode 100644 index 0000000000..81a54a14a5 --- /dev/null +++ b/apps/api/src/billing/services/paying-user/paying-user.ts @@ -0,0 +1,14 @@ +import assert from "http-assert"; + +import type { Require } from "@src/core/types/require.type"; +import type { UserOutput } from "@src/user/repositories"; + +export type PayingUser = Require; + +export function assertIsPayingUser(user: T): asserts user is T & PayingUser { + assert(isPayingUser(user), 402, "User payments are not set up."); +} + +export function isPayingUser(user: T): user is T & PayingUser { + return !!user.stripeCustomerId; +} diff --git a/apps/api/src/billing/services/refill/refill.service.ts b/apps/api/src/billing/services/refill/refill.service.ts index 6ac6fb60e6..31d848d979 100644 --- a/apps/api/src/billing/services/refill/refill.service.ts +++ b/apps/api/src/billing/services/refill/refill.service.ts @@ -6,8 +6,9 @@ import { singleton } from "tsyringe"; import { type BillingConfig, InjectBillingConfig } from "@src/billing/providers"; import { type UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; -import { ManagedUserWalletService, WalletInitializerService } from "@src/billing/services"; import { BalancesService } from "@src/billing/services/balances/balances.service"; +import { ManagedUserWalletService } from "@src/billing/services/managed-user-wallet/managed-user-wallet.service"; +import { WalletInitializerService } from "@src/billing/services/wallet-initializer/wallet-initializer.service"; import { Semaphore } from "@src/core/lib/semaphore.decorator"; import { AnalyticsService } from "@src/core/services/analytics/analytics.service"; diff --git a/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts b/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts index 87e94181b8..dd51dee156 100644 --- a/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts +++ b/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts @@ -3,6 +3,7 @@ import Stripe from "stripe"; import { singleton } from "tsyringe"; import { CheckoutSessionRepository, PaymentMethodRepository } from "@src/billing/repositories"; +import { assertIsPayingUser } from "@src/billing/services/paying-user/paying-user"; import { RefillService } from "@src/billing/services/refill/refill.service"; import { StripeService } from "@src/billing/services/stripe/stripe.service"; import { WithTransaction } from "@src/core"; @@ -140,17 +141,26 @@ export class StripeWebhookService { return; } - await this.paymentMethodRepository.create({ - userId: user.id, - fingerprint, - paymentMethodId: paymentMethod.id - }); + const count = await this.paymentMethodRepository.countByUserId(user.id); + const isDefault = count === 0; + + assertIsPayingUser(user); + + await Promise.all([ + this.paymentMethodRepository.create({ + userId: user.id, + fingerprint, + paymentMethodId: paymentMethod.id, + isDefault + }), + ...(isDefault ? [this.stripe.markRemotePaymentMethodAsDefault(paymentMethod.id, user)] : []) + ]); this.logger.info({ event: "PAYMENT_METHOD_ATTACHED", paymentMethodId: paymentMethod.id, userId: user.id, - fingerprint + isDefault }); } diff --git a/apps/api/src/billing/services/stripe/stripe.service.ts b/apps/api/src/billing/services/stripe/stripe.service.ts index 63b569fe2d..d06199e4d5 100644 --- a/apps/api/src/billing/services/stripe/stripe.service.ts +++ b/apps/api/src/billing/services/stripe/stripe.service.ts @@ -1,3 +1,4 @@ +import type { AnyAbility } from "@casl/ability"; import { stringify } from "csv-stringify"; import assert from "http-assert"; import orderBy from "lodash/orderBy"; @@ -9,9 +10,11 @@ import { PaymentIntentResult, PaymentMethodValidationResult, Transaction } from import { PaymentMethodRepository } from "@src/billing/repositories"; import { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; import { RefillService } from "@src/billing/services/refill/refill.service"; +import { WithTransaction } from "@src/core"; import { LoggerService } from "@src/core/providers/logging.provider"; import { TransactionCsvRow } from "@src/types/transactions"; import { UserOutput, UserRepository } from "@src/user/repositories/user/user.repository"; +import { PayingUser } from "../paying-user/paying-user"; const logger = LoggerService.forContext("StripeService"); @@ -26,6 +29,9 @@ interface StripePrices { isCustom: boolean; currency: string; } + +export type PaymentMethod = Stripe.PaymentMethod & { validated: boolean }; + @singleton() export class StripeService extends Stripe { readonly isProduction = this.billingConfig.get("STRIPE_SECRET_KEY").startsWith("sk_live"); @@ -97,7 +103,7 @@ export class StripeService extends Stripe { return orderBy(responsePrices, ["isCustom", "unitAmount"], ["asc", "asc"]) as StripePrices[]; } - async getPaymentMethods(userId: string, customerId: string): Promise<(Stripe.PaymentMethod & { validated: boolean })[]> { + async getPaymentMethods(userId: string, customerId: string): Promise { const [paymentMethods, dbPaymentMethods] = await Promise.all([ this.paymentMethods.list({ customer: customerId @@ -113,6 +119,66 @@ export class StripeService extends Stripe { .sort((a, b) => b.created - a.created); } + async getDefaultPaymentMethod(user: PayingUser, ability: AnyAbility): Promise { + const [customer, local] = await Promise.all([ + this.customers.retrieve(user.stripeCustomerId, { + expand: ["invoice_settings.default_payment_method"] + }), + this.paymentMethodRepository.accessibleBy(ability, "read").findDefaultByUserId(user.id) + ]); + + assert(!customer.deleted, 402, "Payment account has been deleted"); + + const remote = customer.invoice_settings.default_payment_method as Stripe.PaymentMethod; + + assert(local, 404, "Default payment method not found", { source: "database" }); + assert(remote, 404, "Default payment method not found", { source: "stripe" }); + + return { ...remote, validated: local.isValidated }; + } + + async hasPaymentMethod(paymentMethodId: string, user: UserOutput): Promise { + const paymentMethod = await this.paymentMethods.retrieve(paymentMethodId); + const customerId = typeof paymentMethod.customer === "string" ? paymentMethod.customer : paymentMethod.customer?.id; + + return customerId === user.stripeCustomerId; + } + + @WithTransaction() + async markPaymentMethodAsDefault(paymentMethodId: string, user: PayingUser, ability: AnyAbility): Promise { + const [local, remote] = await Promise.all([ + this.paymentMethodRepository.accessibleBy(ability, "update").markAsDefault(paymentMethodId), + this.paymentMethods.retrieve(paymentMethodId) + ]); + + assert(remote, 404, "Payment method not found", { source: "stripe" }); + + if (local) { + await this.markRemotePaymentMethodAsDefault(paymentMethodId, user); + return { ...remote, validated: local.isValidated }; + } + + const fingerprint = remote.card?.fingerprint; + + assert(fingerprint, 403, "Payment method fingerprint is missing"); + + const newLocal = await this.paymentMethodRepository.accessibleBy(ability, "create").createAsDefault({ + userId: user.id, + fingerprint, + paymentMethodId + }); + + await this.markRemotePaymentMethodAsDefault(paymentMethodId, user); + + return { ...remote, validated: newLocal.isValidated }; + } + + async markRemotePaymentMethodAsDefault(paymentMethodId: string, user: PayingUser): Promise { + await this.customers.update(user.stripeCustomerId, { + invoice_settings: { default_payment_method: paymentMethodId } + }); + } + async createPaymentIntent(params: { customer: string; payment_method: string; @@ -120,22 +186,30 @@ export class StripeService extends Stripe { currency: string; confirm: boolean; metadata?: Record; + idempotencyKey?: string; }): Promise { - // Convert amount to cents for stripe - const amountCents = Math.round(params.amount * 100); - - const paymentIntent = await this.paymentIntents.create({ - customer: params.customer, - payment_method: params.payment_method, - amount: amountCents, - currency: params.currency, - confirm: params.confirm, - metadata: params.metadata, - automatic_payment_methods: { - enabled: true, - allow_redirects: "never" + const amountInCents = Math.round(params.amount * 100); + + const createOptions: Parameters = [ + { + customer: params.customer, + payment_method: params.payment_method, + amount: amountInCents, + currency: params.currency, + confirm: params.confirm, + metadata: params.metadata, + automatic_payment_methods: { + enabled: true, + allow_redirects: "never" + } } - }); + ]; + + if (params.idempotencyKey) { + createOptions.push({ idempotencyKey: params.idempotencyKey }); + } + + const paymentIntent = await this.paymentIntents.create(...createOptions); switch (paymentIntent.status) { case "succeeded": @@ -314,8 +388,7 @@ export class StripeService extends Stripe { } async getCoupon(couponId: string) { - const coupon = await this.coupons.retrieve(couponId); - return coupon; + return await this.coupons.retrieve(couponId); } async getCustomerTransactions( diff --git a/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.spec.ts b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.spec.ts new file mode 100644 index 0000000000..5e34d357ce --- /dev/null +++ b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.spec.ts @@ -0,0 +1,306 @@ +import { faker } from "@faker-js/faker"; +import createHttpError from "http-errors"; +import { mock } from "jest-mock-extended"; + +import { WalletBalanceReloadCheck } from "@src/billing/events/wallet-balance-reload-check"; +import type { WalletSettingRepository } from "@src/billing/repositories"; +import type { BalancesService } from "@src/billing/services/balances/balances.service"; +import type { StripeService } from "@src/billing/services/stripe/stripe.service"; +import type { JobMeta, JobQueueService, LoggerService } from "@src/core"; +import type { JobPayload } from "../../../core"; +import { WalletBalanceReloadCheckHandler } from "./wallet-balance-reload-check.handler"; + +import { generateBalance } from "@test/seeders/balance.seeder"; +import { generateMergedPaymentMethod as generatePaymentMethod } from "@test/seeders/payment-method.seeder"; +import { UserSeeder } from "@test/seeders/user.seeder"; +import { UserWalletSeeder } from "@test/seeders/user-wallet.seeder"; +import { generateWalletSetting } from "@test/seeders/wallet-setting.seeder"; + +describe(WalletBalanceReloadCheckHandler.name, () => { + describe("handle", () => { + it("triggers reload when balance is below threshold", async () => { + const { + handler, + walletSettingRepository, + balancesService, + stripeService, + jobQueueService, + loggerService, + walletSettingWithWallet, + walletSetting, + wallet, + job, + jobMeta + } = setup(); + const paymentMethod = generatePaymentMethod(); + const balance = generateBalance({ balance: 15.0, deployments: 0, total: 15.0 }); + walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(walletSettingWithWallet); + balancesService.getFullBalanceInFiat.mockResolvedValue(balance); + stripeService.getDefaultPaymentMethod.mockResolvedValue(paymentMethod); + jobQueueService.enqueue.mockResolvedValue(faker.string.uuid()); + + await handler.handle(job, jobMeta); + + expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); + expect(stripeService.createPaymentIntent).toHaveBeenCalledWith({ + customer: walletSettingWithWallet.user.stripeCustomerId, + payment_method: paymentMethod.id, + amount: walletSetting.autoReloadAmount, + currency: "usd", + confirm: true, + idempotencyKey: `${WalletBalanceReloadCheck.name}.${jobMeta.id}` + }); + expect(loggerService.info).toHaveBeenCalledWith( + expect.objectContaining({ + event: "WALLET_BALANCE_RELOADED", + walletAddress: wallet.address, + balance: 15.0, + threshold: 30.0, + amount: 100.0 + }) + ); + }); + + it("triggers reload when balance equals threshold", async () => { + const { handler, walletSettingRepository, balancesService, stripeService, jobQueueService, walletSettingWithWallet, walletSetting, job, jobMeta } = + setup(); + const paymentMethod = generatePaymentMethod(); + const balance = generateBalance({ balance: 30.0, deployments: 0, total: 30.0 }); + walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(walletSettingWithWallet); + balancesService.getFullBalanceInFiat.mockResolvedValue(balance); + stripeService.getDefaultPaymentMethod.mockResolvedValue(paymentMethod); + jobQueueService.enqueue.mockResolvedValue(faker.string.uuid()); + + await handler.handle(job, jobMeta); + + expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); + expect(stripeService.createPaymentIntent).toHaveBeenCalledWith({ + customer: walletSettingWithWallet.user.stripeCustomerId, + payment_method: paymentMethod.id, + amount: walletSetting.autoReloadAmount, + currency: "usd", + confirm: true, + idempotencyKey: `${WalletBalanceReloadCheck.name}.${jobMeta.id}` + }); + expect(jobQueueService.enqueue).toHaveBeenCalled(); + }); + + it("does not trigger reload when balance is above threshold", async () => { + const { handler, walletSettingRepository, balancesService, stripeService, jobQueueService, loggerService, walletSettingWithWallet, job, jobMeta } = + setup(); + const balance = generateBalance({ balance: 50.0, deployments: 0, total: 50.0 }); + walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(walletSettingWithWallet); + balancesService.getFullBalanceInFiat.mockResolvedValue(balance); + stripeService.getDefaultPaymentMethod.mockResolvedValue(generatePaymentMethod()); + jobQueueService.enqueue.mockResolvedValue(faker.string.uuid()); + + await handler.handle(job, jobMeta); + + expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); + expect(stripeService.createPaymentIntent).not.toHaveBeenCalled(); + expect(loggerService.info).toHaveBeenCalledWith( + expect.objectContaining({ + event: "WALLET_BALANCE_RELOAD_SKIPPED" + }) + ); + expect(jobQueueService.enqueue).toHaveBeenCalled(); + }); + + it("re-enqueues next check and updates job ID", async () => { + const { handler, walletSettingRepository, balancesService, stripeService, jobQueueService, walletSettingWithWallet, walletSetting, job, jobMeta } = + setup(); + const jobId = faker.string.uuid(); + const balance = generateBalance({ balance: 50.0, deployments: 0, total: 50.0 }); + walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(walletSettingWithWallet); + balancesService.getFullBalanceInFiat.mockResolvedValue(balance); + stripeService.getDefaultPaymentMethod.mockResolvedValue(generatePaymentMethod()); + jobQueueService.enqueue.mockResolvedValue(jobId); + + await handler.handle(job, jobMeta); + + expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); + expect(jobQueueService.enqueue).toHaveBeenCalledWith( + expect.any(WalletBalanceReloadCheck), + expect.objectContaining({ + singletonKey: `WalletBalanceReloadCheck.${job.userId}` + }) + ); + expect(walletSettingRepository.updateById).toHaveBeenCalledWith(walletSetting.id, { autoReloadJobId: jobId }); + }); + + it("does not update job ID when enqueue returns null", async () => { + const { handler, walletSettingRepository, balancesService, stripeService, jobQueueService, walletSettingWithWallet, job, jobMeta } = setup(); + const balance = generateBalance({ balance: 50.0, deployments: 0, total: 50.0 }); + walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(walletSettingWithWallet); + balancesService.getFullBalanceInFiat.mockResolvedValue(balance); + stripeService.getDefaultPaymentMethod.mockResolvedValue(generatePaymentMethod()); + jobQueueService.enqueue.mockResolvedValue(null); + + await handler.handle(job, jobMeta); + + expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); + expect(walletSettingRepository.updateById).not.toHaveBeenCalled(); + }); + + it("logs validation error when wallet setting not found", async () => { + const { handler, walletSettingRepository, loggerService, job, jobMeta } = setup(); + walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(undefined); + + await handler.handle(job, jobMeta); + + expect(loggerService.info).toHaveBeenCalledWith({ + event: "WALLET_SETTING_NOT_FOUND", + message: "Wallet setting not found. Skipping wallet balance reload check.", + userId: job.userId + }); + expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); + }); + + it("logs validation error when auto reload is disabled", async () => { + const { handler, walletSettingRepository, loggerService, walletSettingWithWallet, job, jobMeta } = setup(); + const disabledSetting = { ...walletSettingWithWallet, autoReloadEnabled: false }; + walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(disabledSetting); + + await handler.handle(job, jobMeta); + + expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); + expect(loggerService.info).toHaveBeenCalledWith({ + event: "AUTO_RELOAD_DISABLED", + message: "Auto reload disabled. Skipping wallet balance reload check.", + userId: job.userId + }); + }); + + it("logs validation error when auto reload threshold is not set", async () => { + const { handler, walletSettingRepository, loggerService, walletSettingWithWallet, job, jobMeta } = setup(); + const invalidSetting = { ...walletSettingWithWallet, autoReloadThreshold: undefined }; + walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(invalidSetting); + + await handler.handle(job, jobMeta); + + expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); + expect(loggerService.info).toHaveBeenCalledWith({ + event: "AUTO_RELOAD_THRESHOLD_NOT_SET", + message: "Auto reload threshold not set. Skipping wallet balance reload check.", + userId: job.userId + }); + }); + + it("logs validation error when auto reload amount is not set", async () => { + const { handler, walletSettingRepository, loggerService, walletSettingWithWallet, job, jobMeta } = setup(); + const invalidSetting = { ...walletSettingWithWallet, autoReloadAmount: undefined }; + walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(invalidSetting); + + await handler.handle(job, jobMeta); + + expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); + expect(loggerService.info).toHaveBeenCalledWith({ + event: "AUTO_RELOAD_AMOUNT_NOT_SET", + message: "Auto reload amount not set. Skipping wallet balance reload check.", + userId: job.userId + }); + }); + + it("logs validation error when wallet is not initialized", async () => { + const { handler, walletSettingRepository, loggerService, walletSettingWithWallet, job, jobMeta } = setup(); + const walletWithoutAddress = UserWalletSeeder.create({ address: null }); + const settingWithUninitializedWallet = { ...walletSettingWithWallet, wallet: walletWithoutAddress }; + walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(settingWithUninitializedWallet); + + await handler.handle(job, jobMeta); + + expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); + expect(loggerService.info).toHaveBeenCalledWith({ + event: "WALLET_NOT_INITIALIZED", + message: "Wallet not initialized. Skipping wallet balance reload check.", + userId: job.userId + }); + }); + + it("logs validation error when user stripe customer ID is not set", async () => { + const { handler, walletSettingRepository, loggerService, walletSettingWithWallet, job, jobMeta } = setup(); + const userWithoutStripe = { ...walletSettingWithWallet.user, stripeCustomerId: null }; + const settingWithoutStripe = { ...walletSettingWithWallet, user: userWithoutStripe }; + walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(settingWithoutStripe); + + await handler.handle(job, jobMeta); + + expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); + expect(loggerService.info).toHaveBeenCalledWith({ + event: "USER_STRIPE_CUSTOMER_ID_NOT_SET", + message: "User stripe customer ID not set. Skipping wallet balance reload check.", + userId: job.userId + }); + }); + + it("logs validation error when default payment method cannot be retrieved", async () => { + const { handler, walletSettingRepository, balancesService, stripeService, loggerService, walletSettingWithWallet, job, jobMeta } = setup(); + const balance = generateBalance({ balance: 15.0, deployments: 0, total: 15.0 }); + walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(walletSettingWithWallet); + balancesService.getFullBalanceInFiat.mockResolvedValue(balance); + const error = createHttpError(404, "Default payment method not found", { source: "stripe" }); + stripeService.getDefaultPaymentMethod.mockRejectedValue(error); + + await handler.handle(job, jobMeta); + + expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); + expect(loggerService.info).toHaveBeenCalledWith({ + event: "ERROR_RETRIEVING_DEFAULT_PAYMENT_METHOD", + message: "Default payment method not found", + source: "stripe", + userId: job.userId + }); + }); + }); + + function setup() { + const user = UserSeeder.create(); + const userWithStripe = { ...user, stripeCustomerId: faker.string.uuid() }; + const wallet = UserWalletSeeder.create({ userId: user.id }); + const walletSetting = generateWalletSetting({ + userId: user.id, + walletId: wallet.id, + autoReloadEnabled: true, + autoReloadThreshold: 30.0, + autoReloadAmount: 100.0 + }); + const walletSettingWithWallet = { + ...walletSetting, + wallet: { + address: wallet.address!, + isOldWallet: wallet.isOldWallet + }, + user: userWithStripe + }; + const job: JobPayload = { + userId: user.id, + version: 1 + }; + const jobMeta: JobMeta = { + id: faker.string.uuid() + }; + + const walletSettingRepository = mock(); + const balancesService = mock(); + const stripeService = mock(); + stripeService.getDefaultPaymentMethod.mockResolvedValue(generatePaymentMethod()); + const jobQueueService = mock(); + const loggerService = mock(); + + const handler = new WalletBalanceReloadCheckHandler(walletSettingRepository, balancesService, jobQueueService, stripeService, loggerService); + + return { + handler, + walletSettingRepository, + balancesService, + stripeService, + jobQueueService, + loggerService, + walletSetting, + walletSettingWithWallet, + wallet, + job, + jobMeta + }; + } +}); diff --git a/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts index a12092e11a..0b2dd913a8 100644 --- a/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts +++ b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts @@ -1,7 +1,39 @@ +import { createMongoAbility } from "@casl/ability"; +import addDays from "date-fns/addDays"; +import { isHttpError } from "http-errors"; +import { Err, Ok, Result } from "ts-results"; import { singleton } from "tsyringe"; import { WalletBalanceReloadCheck } from "@src/billing/events/wallet-balance-reload-check"; -import { JobHandler } from "@src/core"; +import type { GetBalancesResponseOutput } from "@src/billing/http-schemas/balance.schema"; +import { UserWalletOutput, WalletSettingOutput, WalletSettingRepository } from "@src/billing/repositories"; +import { BalancesService } from "@src/billing/services/balances/balances.service"; +import { PaymentMethod, StripeService } from "@src/billing/services/stripe/stripe.service"; +import { JobHandler, JobMeta, JobPayload, JobQueueService, LoggerService } from "@src/core"; +import { isPayingUser, PayingUser } from "../paying-user/paying-user"; + +type ValidationError = { + event: string; + message: string; + source?: string; +}; + +type Require = Omit & { + [P in K]-?: NonNullable; +}; + +type InitializedWallet = Require, "address">; +type ActionableWalletSetting = Require< + Pick, + "autoReloadThreshold" | "autoReloadAmount" +>; + +type Resources = { + walletSetting: ActionableWalletSetting; + wallet: InitializedWallet; + user: PayingUser; +}; +type AllResources = Resources & { balance: GetBalancesResponseOutput["data"]["total"]; paymentMethod: PaymentMethod }; @singleton() export class WalletBalanceReloadCheckHandler implements JobHandler { @@ -11,7 +43,192 @@ export class WalletBalanceReloadCheckHandler implements JobHandler, job: JobMeta): Promise { + const resourcesResult = await this.#collectResources(payload); + + if (resourcesResult.ok) { + await this.#tryToReload({ ...resourcesResult.val, job }); + await this.#scheduleNextCheck(resourcesResult.val); + } else { + return this.#finishWithValidationError(resourcesResult.val, payload.userId); + } + } + + async #collectResources(job: JobPayload): Promise> { + const walletResult = await this.#getValidWalletResources(job.userId); + + if (!walletResult.ok) { + return walletResult; + } + + const { wallet, user } = walletResult.val; + + const paymentMethod = await this.#getDefaultPaymentMethod(user); + + if (!paymentMethod.ok) { + return paymentMethod; + } + + const balance = await this.balancesService.getFullBalanceInFiat(wallet.address, !!wallet.isOldWallet); + + return Ok({ ...walletResult.val, paymentMethod: paymentMethod.val, balance: balance.total }); + } + + async #getValidWalletResources(userId: JobPayload["userId"]): Promise> { + const walletSettingWithWallet = await this.walletSettingRepository.findInternalByUserIdWithRelations(userId); + + if (!walletSettingWithWallet) { + return Err({ + event: "WALLET_SETTING_NOT_FOUND", + message: "Wallet setting not found. Skipping wallet balance reload check." + }); + } + + const { wallet, user, ...walletSetting } = walletSettingWithWallet; + + if (!walletSetting.autoReloadEnabled) { + return Err({ + event: "AUTO_RELOAD_DISABLED", + message: "Auto reload disabled. Skipping wallet balance reload check." + }); + } + + const { autoReloadAmount, autoReloadThreshold } = walletSetting; + + if (typeof autoReloadThreshold === "undefined") { + return Err({ + event: "AUTO_RELOAD_THRESHOLD_NOT_SET", + message: "Auto reload threshold not set. Skipping wallet balance reload check." + }); + } + + if (typeof autoReloadAmount === "undefined") { + return Err({ + event: "AUTO_RELOAD_AMOUNT_NOT_SET", + message: "Auto reload amount not set. Skipping wallet balance reload check." + }); + } + + const { address } = wallet; + + if (!address) { + return Err({ + event: "WALLET_NOT_INITIALIZED", + message: "Wallet not initialized. Skipping wallet balance reload check." + }); + } + + if (!isPayingUser(user)) { + return Err({ + event: "USER_STRIPE_CUSTOMER_ID_NOT_SET", + message: "User stripe customer ID not set. Skipping wallet balance reload check." + }); + } + + return Ok({ + walletSetting: { ...walletSetting, autoReloadAmount, autoReloadThreshold }, + wallet: { ...wallet, address }, + user + }); + } + + async #getDefaultPaymentMethod(user: PayingUser): Promise> { + try { + return Ok( + await this.stripeService.getDefaultPaymentMethod( + user, + createMongoAbility([ + { + action: "read", + subject: "PaymentMethod" + } + ]) + ) + ); + } catch (error) { + if (isHttpError(error)) { + return Err({ + event: "ERROR_RETRIEVING_DEFAULT_PAYMENT_METHOD", + message: error.message, + source: error.source + }); + } + + throw error; + } + } + + #finishWithValidationError(error: ValidationError, userId: JobPayload["userId"]): void { + this.loggerService.info({ + ...error, + userId: userId + }); + } + + async #tryToReload(resources: AllResources & { job: JobMeta }): Promise { + const log = { + walletAddress: resources.wallet.address, + balance: resources.balance, + threshold: resources.walletSetting.autoReloadThreshold, + amount: resources.walletSetting.autoReloadAmount + }; + + try { + if (resources.walletSetting.autoReloadThreshold >= resources.balance) { + await this.stripeService.createPaymentIntent({ + customer: resources.user.stripeCustomerId, + payment_method: resources.paymentMethod.id, + amount: resources.walletSetting.autoReloadAmount, + currency: "usd", + confirm: true, + idempotencyKey: `${WalletBalanceReloadCheck.name}.${resources.job.id}` + }); + this.loggerService.info({ + ...log, + event: "WALLET_BALANCE_RELOADED" + }); + } else { + this.loggerService.info({ + ...log, + event: "WALLET_BALANCE_RELOAD_SKIPPED" + }); + } + } catch (error) { + this.loggerService.error({ + event: "WALLET_BALANCE_RELOAD_FAILED", + error: error + }); + throw error; + } + } + + async #scheduleNextCheck(resources: Resources): Promise { + const jobId = await this.jobQueueService.enqueue(new WalletBalanceReloadCheck({ userId: resources.user.id }), { + singletonKey: `${WalletBalanceReloadCheck.name}.${resources.user.id}`, + startAfter: await this.#calculateNextCheckDate(resources.wallet) + }); + + if (jobId) { + await this.walletSettingRepository.updateById(resources.walletSetting.id, { autoReloadJobId: jobId }); + } + } - async handle(): Promise {} + async #calculateNextCheckDate(wallet: InitializedWallet): Promise { + this.loggerService.info({ + event: "CALCULATING_NEXT_CHECK_DATE", + walletAddress: wallet.address, + note: "Implementation pending..." + }); + return addDays(new Date(), 1); + } } 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 9183d58370..d6a8b902d2 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 @@ -2,7 +2,7 @@ import { singleton } from "tsyringe"; import { AuthService } from "@src/auth/services/auth.service"; import { TrialStarted } from "@src/billing/events/trial-started"; -import { UserWalletInput, UserWalletPublicOutput, UserWalletRepository } from "@src/billing/repositories"; +import { UserWalletPublicOutput, UserWalletRepository } from "@src/billing/repositories"; import { DomainEventsService } from "@src/core/services/domain-events/domain-events.service"; import { FeatureFlags } from "@src/core/services/feature-flags/feature-flags"; import { FeatureFlagsService } from "@src/core/services/feature-flags/feature-flags.service"; @@ -50,7 +50,7 @@ export class WalletInitializerService { return walletOutput; } - async initialize(userId: UserWalletInput["userId"]) { + async initialize(userId: string) { const { id } = await this.userWalletRepository.create({ userId }); const wallet = await this.walletManager.createWallet({ addressIndex: id, useOldWallet: false }); return await this.userWalletRepository.updateById( diff --git a/apps/api/src/billing/services/wallet-settings/wallet-settings.service.spec.ts b/apps/api/src/billing/services/wallet-settings/wallet-settings.service.spec.ts index b9605289e7..f5723e122a 100644 --- a/apps/api/src/billing/services/wallet-settings/wallet-settings.service.spec.ts +++ b/apps/api/src/billing/services/wallet-settings/wallet-settings.service.spec.ts @@ -7,9 +7,12 @@ import { v4 as uuidv4 } from "uuid"; import type { AuthService } from "@src/auth/services/auth.service"; import { WalletBalanceReloadCheck } from "@src/billing/events/wallet-balance-reload-check"; import type { UserWalletRepository, WalletSettingRepository } from "@src/billing/repositories"; -import type { JobQueueService } from "@src/core"; +import type { PaymentMethod, StripeService } from "@src/billing/services/stripe/stripe.service"; +import type { JobQueueService, TxService } from "@src/core"; +import type { UserRepository } from "@src/user/repositories"; import { WalletSettingService } from "./wallet-settings.service"; +import { generatePaymentMethod } from "@test/seeders/payment-method.seeder"; import { UserSeeder } from "@test/seeders/user.seeder"; import { UserWalletSeeder } from "@test/seeders/user-wallet.seeder"; import { generateWalletSetting } from "@test/seeders/wallet-setting.seeder"; @@ -47,7 +50,7 @@ describe(WalletSettingService.name, () => { autoReloadEnabled: false, autoReloadThreshold: 20.75 }); - walletSettingRepository.findByUserId.mockResolvedValue(walletSetting as any); + walletSettingRepository.findByUserId.mockResolvedValue(walletSetting); walletSettingRepository.updateById.mockResolvedValue(updatedSetting as any); const result = await service.upsertWalletSetting(user.id, { @@ -275,11 +278,18 @@ describe(WalletSettingService.name, () => { function setup() { const user = UserSeeder.create(); + const userWithStripe = { ...user, stripeCustomerId: faker.string.uuid() }; const userWallet = UserWalletSeeder.create({ userId: user.id }); const walletSettingRepository = mock(); walletSettingRepository.accessibleBy.mockReturnValue(walletSettingRepository); const userWalletRepository = mock(); userWalletRepository.findOneByUserId.mockResolvedValue(userWallet); + const userRepository = mock(); + userRepository.findById.mockResolvedValue(userWithStripe); + const paymentMethod = { ...generatePaymentMethod(), validated: true }; + const stripeService = mock({ + getDefaultPaymentMethod: jest.fn().mockResolvedValue(paymentMethod as PaymentMethod) + }); const walletSetting = generateWalletSetting({ userId: user.id }); walletSettingRepository.findByUserId.mockResolvedValue(walletSetting); const ability = createMongoAbility(); @@ -292,16 +302,29 @@ describe(WalletSettingService.name, () => { const jobQueueService = mock({ cancel: jest.fn().mockResolvedValue(undefined) }); - const service = new WalletSettingService(walletSettingRepository, userWalletRepository, authService, jobQueueService); + const txService = mock({ + transaction: jest.fn(async (cb: () => Promise) => await cb()) as TxService["transaction"] + }); + const service = new WalletSettingService( + walletSettingRepository, + userWalletRepository, + userRepository, + stripeService, + authService, + jobQueueService, + txService + ); const { autoReloadJobId, ...publicSetting } = walletSetting; return { - user, + user: userWithStripe, userWallet, walletSetting, publicSetting, walletSettingRepository, userWalletRepository, + userRepository, + stripeService, authService, jobQueueService, jobId, diff --git a/apps/api/src/billing/services/wallet-settings/wallet-settings.service.ts b/apps/api/src/billing/services/wallet-settings/wallet-settings.service.ts index 9c16cc2e19..64a939672d 100644 --- a/apps/api/src/billing/services/wallet-settings/wallet-settings.service.ts +++ b/apps/api/src/billing/services/wallet-settings/wallet-settings.service.ts @@ -1,15 +1,16 @@ import assert from "http-assert"; -import { PostgresError } from "postgres"; import { singleton } from "tsyringe"; import { v4 as uuidv4 } from "uuid"; import { AuthService } from "@src/auth/services/auth.service"; import { WalletBalanceReloadCheck } from "@src/billing/events/wallet-balance-reload-check"; import { UserWalletRepository, type WalletSettingOutput, WalletSettingRepository } from "@src/billing/repositories"; -import { JobQueueService, WithTransaction } from "@src/core"; -import { UserOutput } from "@src/user/repositories"; +import { StripeService } from "@src/billing/services/stripe/stripe.service"; +import { JobQueueService, TxService, WithTransaction } from "@src/core"; +import { isUniqueViolation } from "@src/core/repositories/base.repository"; +import { UserOutput, UserRepository } from "@src/user/repositories"; -export interface WalletSetting { +export interface WalletSettingInput { autoReloadEnabled?: boolean; autoReloadThreshold?: number; autoReloadAmount?: number; @@ -20,8 +21,11 @@ export class WalletSettingService { constructor( private readonly walletSettingRepository: WalletSettingRepository, private readonly userWalletRepository: UserWalletRepository, + private readonly userRepository: UserRepository, + private readonly stripeService: StripeService, private readonly authService: AuthService, - private readonly jobQueueService: JobQueueService + private readonly jobQueueService: JobQueueService, + private readonly txService: TxService ) {} async getWalletSetting(userId: string): Promise | undefined> { @@ -39,41 +43,41 @@ export class WalletSettingService { } @WithTransaction() - async upsertWalletSetting(userId: UserOutput["id"], input: WalletSetting): Promise> { - let mutationResult = await this.update(userId, input); + async upsertWalletSetting(userId: UserOutput["id"], input: WalletSettingInput): Promise> { + let mutationResult = await this.#update(userId, input); if (!mutationResult.next) { - mutationResult = await this.create(userId, input); + mutationResult = await this.#create(userId, input); } - await this.arrangeSchedule(mutationResult.prev, mutationResult.next); + await this.#arrangeSchedule(mutationResult.prev, mutationResult.next); const { autoReloadJobId, ...setting } = mutationResult.next!; return setting; } - private async update(userId: UserOutput["id"], settings: WalletSetting): Promise<{ prev?: WalletSettingOutput; next?: WalletSettingOutput }> { + async #update(userId: UserOutput["id"], settings: WalletSettingInput): Promise<{ prev?: WalletSettingOutput; next?: WalletSettingOutput }> { const { ability } = this.authService; const prev = await this.walletSettingRepository.accessibleBy(ability, "read").findByUserId(userId); - if (prev) { - this.validate(settings, prev); - const next = await this.walletSettingRepository.accessibleBy(ability, "update").updateById(prev.id, settings, { returning: true }); + if (!prev) { + return {}; + } - if (!next) { - return {}; - } + await this.#validate({ next: settings, prev, userId }); + const next = await this.walletSettingRepository.accessibleBy(ability, "update").updateById(prev.id, settings, { returning: true }); - return { prev, next }; + if (!next) { + return {}; } - return {}; + return { prev, next }; } - private async create(userId: UserOutput["id"], settings: WalletSetting): Promise<{ prev?: WalletSettingOutput; next: WalletSettingOutput }> { - this.validate(settings); + async #create(userId: UserOutput["id"], settings: WalletSettingInput): Promise<{ prev?: WalletSettingOutput; next: WalletSettingOutput }> { + await this.#validate({ next: settings, userId }); const userWallet = await this.userWalletRepository.findOneByUserId(userId); @@ -88,8 +92,8 @@ export class WalletSettingService { }) }; } catch (error: unknown) { - if (this.isDuplicateError(error)) { - const updatedSettingRetried = await this.update(userId, settings); + if (isUniqueViolation(error)) { + const updatedSettingRetried = await this.#update(userId, settings); assert(updatedSettingRetried.next, 500, "Failed to create a wallet setting"); @@ -102,26 +106,35 @@ export class WalletSettingService { } } - private isDuplicateError(error: unknown): error is PostgresError & { code: "23505" } { - return error instanceof PostgresError && error.code === "23505"; - } - - private validate(settings: WalletSetting, existingSetting?: WalletSettingOutput) { - if (settings.autoReloadEnabled === true) { - const threshold = settings.autoReloadThreshold ?? existingSetting?.autoReloadThreshold; - const amount = settings.autoReloadAmount ?? existingSetting?.autoReloadAmount; + async #validate({ prev, next, userId }: { next: WalletSettingInput; prev?: WalletSettingOutput; userId: string }) { + if (next.autoReloadEnabled) { + const threshold = next.autoReloadThreshold ?? prev?.autoReloadThreshold; + const amount = next.autoReloadAmount ?? prev?.autoReloadAmount; assert( typeof threshold === "number" && typeof amount === "number", 400, '"autoReloadThreshold" and "autoReloadAmount" are required when "autoReloadEnabled" is true' ); + + const user = await this.userRepository.findById(userId); + assert(user, 404, "User Not Found"); + + const { stripeCustomerId } = user; + assert(stripeCustomerId, 404, "User payments not set up"); + + const { ability } = this.authService; + assert( + await this.stripeService.getDefaultPaymentMethod({ ...user, stripeCustomerId }, ability), + 403, + "Default payment method is required to enable automatic wallet balance reload" + ); } } - private async arrangeSchedule(prev?: WalletSettingOutput, next?: WalletSettingOutput) { + async #arrangeSchedule(prev?: WalletSettingOutput, next?: WalletSettingOutput) { if (!prev?.autoReloadEnabled && next?.autoReloadEnabled) { - await this.schedule(next); + await this.#schedule(next); } if (!next?.autoReloadEnabled && next?.autoReloadJobId) { @@ -129,22 +142,24 @@ export class WalletSettingService { } } - private async schedule(walletSetting: WalletSettingOutput) { - if (walletSetting.autoReloadJobId) { - await this.jobQueueService.cancel(WalletBalanceReloadCheck.name, walletSetting.autoReloadJobId); - } + async #schedule(walletSetting: WalletSettingOutput) { + return await this.txService.transaction(async () => { + if (walletSetting.autoReloadJobId) { + await this.jobQueueService.cancel(WalletBalanceReloadCheck.name, walletSetting.autoReloadJobId); + } - const jobId = uuidv4(); - await this.walletSettingRepository.updateById(walletSetting.id, { autoReloadJobId: jobId }); + const jobId = uuidv4(); + await this.walletSettingRepository.updateById(walletSetting.id, { autoReloadJobId: jobId }); - const createdJobId = await this.jobQueueService.enqueue(new WalletBalanceReloadCheck({ userId: walletSetting.userId }), { - singletonKey: `${WalletBalanceReloadCheck.name}.${walletSetting.userId}`, - id: jobId - }); + const createdJobId = await this.jobQueueService.enqueue(new WalletBalanceReloadCheck({ userId: walletSetting.userId }), { + singletonKey: `${WalletBalanceReloadCheck.name}.${walletSetting.userId}`, + id: jobId + }); - assert(createdJobId, 500, "Failed to schedule wallet balance reload check"); + assert(createdJobId, 500, "Failed to schedule wallet balance reload check"); - return jobId; + return jobId; + }); } async deleteWalletSetting(userId: string): Promise { diff --git a/apps/api/src/core/repositories/base.repository.ts b/apps/api/src/core/repositories/base.repository.ts index b0a12e5557..32c021fd50 100644 --- a/apps/api/src/core/repositories/base.repository.ts +++ b/apps/api/src/core/repositories/base.repository.ts @@ -5,7 +5,7 @@ import type { PgTableWithColumns } from "drizzle-orm/pg-core"; import type { SQL } from "drizzle-orm/sql/sql"; import { PostgresError } from "postgres"; -import type { ApiPgDatabase, ApiPgTables, TxService } from "@src/core"; +import type { ApiPgDatabase, ApiPgTables, ApiTransaction, TxService } from "@src/core"; import { DrizzleAbility } from "@src/lib/drizzle-ability/drizzle-ability"; export type AbilityParams = [AnyAbility, Parameters[0]]; @@ -56,6 +56,16 @@ export abstract class BaseRepository< abstract accessibleBy(...abilityParams: AbilityParams): this; + protected async ensureTransaction(cb: (tx: ApiTransaction) => Promise) { + const txCursor = this.txManager.getPgTx(); + + if (txCursor) { + return await cb(txCursor); + } + + return await this.pg.transaction(async tx => await cb(tx)); + } + async create(input: Input): Promise { this.ability?.throwUnlessCanExecute(input); const [item] = await this.cursor.insert(this.table).values(input).returning(); diff --git a/apps/api/src/core/services/job-queue/job-queue.service.spec.ts b/apps/api/src/core/services/job-queue/job-queue.service.spec.ts index 825fd4f330..8d10a1a195 100644 --- a/apps/api/src/core/services/job-queue/job-queue.service.spec.ts +++ b/apps/api/src/core/services/job-queue/job-queue.service.spec.ts @@ -19,8 +19,9 @@ describe(JobQueueService.name, () => { expect(pgBoss.createQueue).toHaveBeenCalledWith("test", { name: "test", retryBackoff: true, - retryDelayMax: 300, - retryLimit: 5 + retryDelayMax: 5 * 60, + retryLimit: 5, + policy: undefined }); }); @@ -51,16 +52,28 @@ describe(JobQueueService.name, () => { expect(pgBoss.createQueue).toHaveBeenCalledWith("test", { name: "test", retryBackoff: true, - retryDelayMax: 300, - retryLimit: 5 + retryDelayMax: 5 * 60, + retryLimit: 5, + policy: undefined }); expect(pgBoss.createQueue).toHaveBeenCalledWith("another", { name: "another", retryBackoff: true, - retryDelayMax: 300, - retryLimit: 5 + retryDelayMax: 5 * 60, + retryLimit: 5, + policy: undefined }); }); + + it("throws error when multiple handlers register for the same queue", async () => { + const handleFn1 = jest.fn().mockResolvedValue(undefined); + const handleFn2 = jest.fn().mockResolvedValue(undefined); + const handler1 = new TestHandler(handleFn1); + const handler2 = new TestHandler(handleFn2); + const { service } = setup(); + + await expect(service.registerHandlers([handler1, handler2])).rejects.toThrow("JobQueue does not support multiple handlers for the same queue: test"); + }); }); describe("enqueue", () => { @@ -74,15 +87,16 @@ describe(JobQueueService.name, () => { const result = await service.enqueue(job, { startAfter: new Date() }); - expect(logger.info).toHaveBeenCalledWith({ - event: "JOB_ENQUEUED", - job - }); expect(pgBoss.send).toHaveBeenCalledWith({ name: job.name, data: { ...job.data, version: job.version }, options: { startAfter: expect.any(Date) } }); + expect(logger.info).toHaveBeenCalledWith({ + event: "JOB_ENQUEUED", + job, + jobId: "job-id-123" + }); expect(result).toBe("job-id-123"); }); @@ -116,8 +130,8 @@ describe(JobQueueService.name, () => { expect(pgBoss.cancel).toHaveBeenCalledWith("test", jobId); expect(logger.info).toHaveBeenCalledWith({ event: "JOB_CANCELLED", - name: "test", - id: jobId + id: jobId, + name: "test" }); }); }); @@ -134,10 +148,10 @@ describe(JobQueueService.name, () => { const handler = new TestHandler(handleFn); const { service, pgBoss, logger } = setup(); - const jobs = [{ id: "1", data: { message: "Job 1", userId: "user-1" } }]; + const job = { id: "1", data: { message: "Job 1", userId: "user-1" } }; - jest.spyOn(pgBoss, "work").mockImplementation(async (queueName: string, options: any, processFn: (jobs: any[]) => Promise) => { - await processFn(jobs); + jest.spyOn(pgBoss, "work").mockImplementation(async (queueName: string, options: unknown, processFn: PgBoss.WorkHandler) => { + await processFn([job as PgBoss.Job]); return "work-id"; }); @@ -147,20 +161,21 @@ describe(JobQueueService.name, () => { expect(pgBoss.createQueue).toHaveBeenCalledWith("test", { name: "test", retryBackoff: true, - retryDelayMax: 300, - retryLimit: 5 + retryDelayMax: 5 * 60, + retryLimit: 5, + policy: undefined }); expect(pgBoss.work).toHaveBeenCalledTimes(5); expect(pgBoss.work).toHaveBeenCalledWith("test", { batchSize: 1 }, expect.any(Function)); expect(logger.info).toHaveBeenCalledWith({ event: "JOB_STARTED", - jobId: jobs[0].id + jobId: job.id }); expect(handleFn).toHaveBeenCalledTimes(5); - expect(handleFn).toHaveBeenCalledWith({ message: "Job 1", userId: "user-1" }); + expect(handleFn).toHaveBeenCalledWith({ message: "Job 1", userId: "user-1" }, { id: job.id }); expect(logger.info).toHaveBeenCalledWith({ event: "JOB_DONE", - jobId: jobs[0].id + jobId: job.id }); }); @@ -170,10 +185,10 @@ describe(JobQueueService.name, () => { const handler = new TestHandler(handleFn); const { service, pgBoss, logger } = setup(); - const jobs = [{ id: "1", data: { message: "Job 1", userId: "user-1" } }]; + const job = { id: "1", data: { message: "Job 1", userId: "user-1" } }; - jest.spyOn(pgBoss, "work").mockImplementation(async (queueName: string, options: any, processFn: (jobs: any[]) => Promise) => { - await processFn(jobs); + jest.spyOn(pgBoss, "work").mockImplementation(async (queueName: string, options: unknown, processFn: PgBoss.WorkHandler) => { + await processFn([job as PgBoss.Job]); return "work-id"; }); @@ -184,27 +199,31 @@ describe(JobQueueService.name, () => { expect((result as PromiseRejectedResult).reason).toBe(error); expect(logger.error).toHaveBeenCalledWith({ event: "JOB_FAILED", - jobId: jobs[0].id, + jobId: job.id, error: (result as PromiseRejectedResult).reason }); expect(handleFn).toHaveBeenCalledTimes(1); + expect(handleFn).toHaveBeenCalledWith({ message: "Job 1", userId: "user-1" }, { id: job.id }); }); it("uses default options when none provided", async () => { const handleFn = jest.fn().mockResolvedValue(undefined); const handler = new TestHandler(handleFn); const { service, pgBoss } = setup(); + const job = { id: "1", data: { message: "Job 1", userId: "user-1" } }; - jest.spyOn(pgBoss, "work").mockImplementation(async (queueName: string, options: any, processFn: (jobs: any[]) => Promise) => { - await processFn([{ id: "1", data: { message: "Job 1", userId: "user-1" } }]); + jest.spyOn(pgBoss, "work").mockImplementation(async (queueName: string, options: unknown, processFn: PgBoss.WorkHandler) => { + await processFn([job as PgBoss.Job]); return "work-id"; }); await service.registerHandlers([handler]); await service.startWorkers(); - expect(handleFn).toHaveBeenCalledTimes(2); + expect(pgBoss.work).toHaveBeenCalledTimes(2); expect(pgBoss.work).toHaveBeenCalledWith("test", { batchSize: 1 }, expect.any(Function)); + expect(handleFn).toHaveBeenCalledTimes(2); + expect(handleFn).toHaveBeenCalledWith({ message: "Job 1", userId: "user-1" }, { id: job.id }); }); }); @@ -279,7 +298,7 @@ describe(JobQueueService.name, () => { }), executionContextService: mock({ set: jest.fn().mockResolvedValue(undefined), - runWithContext: jest.fn(async (cb: () => Promise) => await cb()) + runWithContext: jest.fn(async (cb: () => Promise) => await cb()) as ExecutionContextService["runWithContext"] }) }; diff --git a/apps/api/src/core/services/job-queue/job-queue.service.ts b/apps/api/src/core/services/job-queue/job-queue.service.ts index 200a60c755..bd6737e7f4 100644 --- a/apps/api/src/core/services/job-queue/job-queue.service.ts +++ b/apps/api/src/core/services/job-queue/job-queue.service.ts @@ -80,16 +80,19 @@ export class JobQueueService implements Disposable { * @param options - The custom options to enqueue the job with. */ async enqueue(job: Job, options?: EnqueueOptions): Promise { - this.logger.info({ - event: "JOB_ENQUEUED", - job - }); - - return await this.pgBoss.send({ + const jobId = await this.pgBoss.send({ name: job.name, data: { ...job.data, version: job.version }, options }); + + this.logger.info({ + event: "JOB_ENQUEUED", + job, + jobId + }); + + return jobId; } async cancel(name: string, id: string): Promise { @@ -139,7 +142,7 @@ export class JobQueueService implements Disposable { jobId: job.id }); try { - await handler.handle(job.data); + await handler.handle(job.data, { id: job.id }); this.logger.info({ event: "JOB_DONE", jobId: job.id @@ -202,11 +205,13 @@ export type JobType = { [JOB_NAME]: string; }; +export type JobMeta = Pick; + export interface JobHandler { accepts: JobType; concurrency?: ProcessOptions["concurrency"]; policy?: PgBoss.Queue["policy"]; - handle(payload: JobPayload): Promise; + handle(payload: JobPayload, job: JobMeta): Promise; } export type EnqueueOptions = PgBoss.SendOptions; diff --git a/apps/api/src/core/services/tx/tx.service.ts b/apps/api/src/core/services/tx/tx.service.ts index 1acdf3508f..466a2ba401 100644 --- a/apps/api/src/core/services/tx/tx.service.ts +++ b/apps/api/src/core/services/tx/tx.service.ts @@ -8,15 +8,21 @@ import { type ApiPgDatabase, type ApiPgTables, InjectPg } from "@src/core/provid type TxType = "PG_TX"; +export type ApiTransaction = PgTransaction>; + @singleton() export class TxService { - private readonly storage = new AsyncLocalStorage< - Map>> - >(); + private readonly storage = new AsyncLocalStorage>(); constructor(@InjectPg() private readonly pg: ApiPgDatabase) {} async transaction(cb: () => Promise) { + const existingTx = this.storage.getStore()?.get("PG_TX"); + + if (existingTx) { + return await cb(); + } + return await this.pg.transaction(async tx => { return this.storage.run(new Map(), async () => { this.storage.getStore()?.set("PG_TX", tx); diff --git a/apps/api/src/core/types/require.type.ts b/apps/api/src/core/types/require.type.ts new file mode 100644 index 0000000000..3458d719a1 --- /dev/null +++ b/apps/api/src/core/types/require.type.ts @@ -0,0 +1,3 @@ +export type Require = Omit & { + [P in K]-?: NonNullable; +}; diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index eb891b4dd9..b5a94c4b59 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -13212,6 +13212,52 @@ exports[`API Docs GET /v1/doc returns docs with all routes expected 1`] = ` ], }, }, + "/v1/stripe/payment-methods/default": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": { + "id": { + "type": "string", + }, + }, + "required": [ + "id", + ], + "type": "object", + }, + }, + "required": [ + "data", + ], + "type": "object", + }, + }, + }, + }, + "responses": { + "200": { + "description": "Payment method is marked as the default successfully.", + }, + }, + "security": [ + { + "BearerAuth": [], + }, + { + "ApiKeyAuth": [], + }, + ], + "summary": "Marks a payment method as the default.", + "tags": [ + "Payment", + ], + }, + }, "/v1/stripe/payment-methods/setup": { "post": { "description": "Creates a Stripe SetupIntent that allows users to securely add payment methods to their account. The SetupIntent provides a client secret that can be used with Stripe's frontend SDKs to collect payment method details.", diff --git a/apps/api/test/seeders/balance.seeder.ts b/apps/api/test/seeders/balance.seeder.ts index ac308f23d5..40a1410681 100644 --- a/apps/api/test/seeders/balance.seeder.ts +++ b/apps/api/test/seeders/balance.seeder.ts @@ -2,6 +2,8 @@ import type { Balance } from "@akashnetwork/http-sdk"; import { faker } from "@faker-js/faker"; import { merge } from "lodash"; +import type { GetBalancesResponseOutput } from "@src/billing/http-schemas/balance.schema"; + import { DenomSeeder } from "@test/seeders/denom.seeder"; export class BalanceSeeder { @@ -15,3 +17,18 @@ export class BalanceSeeder { ); } } + +export function generateBalance(overrides: Partial = {}): GetBalancesResponseOutput["data"] { + const balance = overrides.balance ?? faker.number.float({ min: 0, max: 1000, fractionDigits: 2 }); + const deployments = overrides.deployments ?? faker.number.float({ min: 0, max: 1000, fractionDigits: 2 }); + const total = overrides.total ?? parseFloat((balance + deployments).toFixed(2)); + + return merge( + { + balance, + deployments, + total + }, + overrides + ); +} diff --git a/apps/api/test/seeders/database-payment-method.seeder.ts b/apps/api/test/seeders/database-payment-method.seeder.ts index 0903d87485..8b6c1ce3e4 100644 --- a/apps/api/test/seeders/database-payment-method.seeder.ts +++ b/apps/api/test/seeders/database-payment-method.seeder.ts @@ -1,22 +1,15 @@ import { faker } from "@faker-js/faker"; -export interface DatabasePaymentMethod { - id: string; - userId: string; - fingerprint: string; - paymentMethodId: string; - isValidated: boolean; - createdAt: Date; - updatedAt: Date; -} +import type { PaymentMethodOutput } from "@src/billing/repositories"; -export function generateDatabasePaymentMethod(overrides: Partial = {}): DatabasePaymentMethod { - const basePaymentMethod: DatabasePaymentMethod = { +export function generateDatabasePaymentMethod(overrides: Partial = {}) { + const basePaymentMethod: PaymentMethodOutput = { id: faker.string.uuid(), userId: faker.string.uuid(), fingerprint: faker.string.uuid(), paymentMethodId: faker.string.uuid(), isValidated: false, + isDefault: false, createdAt: faker.date.recent(), updatedAt: faker.date.recent() }; diff --git a/apps/api/test/seeders/payment-method.seeder.ts b/apps/api/test/seeders/payment-method.seeder.ts index 1893a16213..639ca4e6e0 100644 --- a/apps/api/test/seeders/payment-method.seeder.ts +++ b/apps/api/test/seeders/payment-method.seeder.ts @@ -2,6 +2,8 @@ import { faker } from "@faker-js/faker"; import { merge } from "lodash"; import type Stripe from "stripe"; +import type { PaymentMethod } from "@src/billing/services/stripe/stripe.service"; + type PaymentMethodOverrides = Omit, "card"> & { card?: Partial | null; }; @@ -52,3 +54,8 @@ export function generatePaymentMethod(overrides: PaymentMethodOverrides = {}): S return merge({}, basePaymentMethod, overrides); } + +export function generateMergedPaymentMethod(overrides: PaymentMethodOverrides & { validated?: boolean } = {}): PaymentMethod { + const { validated, ...stripeOverrides } = overrides; + return merge({ validated: !!validated }, generatePaymentMethod(stripeOverrides)); +} diff --git a/packages/http-sdk/src/stripe/stripe.service.ts b/packages/http-sdk/src/stripe/stripe.service.ts index ea410ae617..512a58554e 100644 --- a/packages/http-sdk/src/stripe/stripe.service.ts +++ b/packages/http-sdk/src/stripe/stripe.service.ts @@ -20,7 +20,6 @@ export class StripeService extends ApiHttpService { super(config); } - // Payment Methods async createSetupIntent(config?: AxiosRequestConfig): Promise { return this.extractApiData(await this.post("/v1/stripe/payment-methods/setup", {}, config)); } @@ -33,12 +32,10 @@ export class StripeService extends ApiHttpService { return this.extractApiData(await this.delete(`/v1/stripe/payment-methods/${paymentMethodId}`)); } - // Customers async updateCustomerOrganization(organization: string): Promise { await this.put("/v1/stripe/customers/organization", { organization }); } - // Coupons async applyCoupon(couponId: string, userId: string): Promise { return this.extractApiData(await this.post("/v1/stripe/coupons/apply", { data: { couponId, userId } })); } @@ -47,7 +44,6 @@ export class StripeService extends ApiHttpService { return this.extractApiData(await this.get("/v1/stripe/coupons/customer-discounts")); } - // Transactions async confirmPayment(params: ConfirmPaymentParams): Promise { return this.extractApiData(await this.post("/v1/stripe/transactions/confirm", { data: params })); } @@ -92,7 +88,6 @@ export class StripeService extends ApiHttpService { ); } - // Prices (legacy endpoint) async findPrices(config?: AxiosRequestConfig): Promise { return this.extractApiData(await this.get("/v1/stripe/prices", config)); } From d9a659c95377c3ce583bc1f7b8b43576f94b4bdf Mon Sep 17 00:00:00 2001 From: Iaroslav Gryshaiev Date: Fri, 28 Nov 2025 15:31:10 +0100 Subject: [PATCH 2/9] fix(billing): address code review comments refs #1779 --- .../billing/services/stripe/stripe.service.ts | 12 ++++++++---- .../wallet-balance-reload-check.handler.ts | 18 +++++++++++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/api/src/billing/services/stripe/stripe.service.ts b/apps/api/src/billing/services/stripe/stripe.service.ts index d06199e4d5..26ff7534b8 100644 --- a/apps/api/src/billing/services/stripe/stripe.service.ts +++ b/apps/api/src/billing/services/stripe/stripe.service.ts @@ -148,7 +148,7 @@ export class StripeService extends Stripe { async markPaymentMethodAsDefault(paymentMethodId: string, user: PayingUser, ability: AnyAbility): Promise { const [local, remote] = await Promise.all([ this.paymentMethodRepository.accessibleBy(ability, "update").markAsDefault(paymentMethodId), - this.paymentMethods.retrieve(paymentMethodId) + this.paymentMethods.retrieve(paymentMethodId, undefined, { timeout: 3_000 }) ]); assert(remote, 404, "Payment method not found", { source: "stripe" }); @@ -174,9 +174,13 @@ export class StripeService extends Stripe { } async markRemotePaymentMethodAsDefault(paymentMethodId: string, user: PayingUser): Promise { - await this.customers.update(user.stripeCustomerId, { - invoice_settings: { default_payment_method: paymentMethodId } - }); + await this.customers.update( + user.stripeCustomerId, + { + invoice_settings: { default_payment_method: paymentMethodId } + }, + { timeout: 3_000 } + ); } async createPaymentIntent(params: { diff --git a/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts index 0b2dd913a8..42de74893e 100644 --- a/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts +++ b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts @@ -15,7 +15,7 @@ import { isPayingUser, PayingUser } from "../paying-user/paying-user"; type ValidationError = { event: string; message: string; - source?: string; + error?: unknown; }; type Require = Omit & { @@ -160,7 +160,7 @@ export class WalletBalanceReloadCheckHandler implements JobHandler Date: Fri, 28 Nov 2025 15:51:02 +0100 Subject: [PATCH 3/9] feat(billing): address payment method isDefault unique index per user refs #1779 --- apps/api/drizzle.config.ts | 8 +- apps/api/drizzle/0023_sad_adam_warlock.sql | 1 - apps/api/drizzle/0023_steady_leech.sql | 3 + apps/api/drizzle/0024_thankful_stick.sql | 1 - apps/api/drizzle/meta/0023_snapshot.json | 31 +- apps/api/drizzle/meta/0024_snapshot.json | 798 ------------------ apps/api/drizzle/meta/_journal.json | 11 +- .../payment-method/payment-method.schema.ts | 3 + 8 files changed, 44 insertions(+), 812 deletions(-) delete mode 100644 apps/api/drizzle/0023_sad_adam_warlock.sql create mode 100644 apps/api/drizzle/0023_steady_leech.sql delete mode 100644 apps/api/drizzle/0024_thankful_stick.sql delete mode 100644 apps/api/drizzle/meta/0024_snapshot.json diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index f5c743a894..3658d0e57f 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -2,13 +2,17 @@ import "@akashnetwork/env-loader"; import { defineConfig } from "drizzle-kit"; -import { config } from "./src/core/config"; +const { POSTGRES_DB_URI } = process.env; + +if (!POSTGRES_DB_URI) { + throw new Error("POSTGRES_DB_URI must be set"); +} export default defineConfig({ schema: ["billing", "user", "deployment", "auth"].map(schema => `./src/${schema}/model-schemas`), out: "./drizzle", dialect: "postgresql", dbCredentials: { - url: config.POSTGRES_DB_URI + url: POSTGRES_DB_URI } }); diff --git a/apps/api/drizzle/0023_sad_adam_warlock.sql b/apps/api/drizzle/0023_sad_adam_warlock.sql deleted file mode 100644 index d32a87eb83..0000000000 --- a/apps/api/drizzle/0023_sad_adam_warlock.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "user_wallets" ALTER COLUMN "user_id" SET NOT NULL; \ No newline at end of file diff --git a/apps/api/drizzle/0023_steady_leech.sql b/apps/api/drizzle/0023_steady_leech.sql new file mode 100644 index 0000000000..0ad920e767 --- /dev/null +++ b/apps/api/drizzle/0023_steady_leech.sql @@ -0,0 +1,3 @@ +ALTER TABLE "user_wallets" ALTER COLUMN "user_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "payment_methods" ADD COLUMN "is_default" boolean DEFAULT false NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "payment_methods_user_id_is_default_unique" ON "payment_methods" USING btree ("user_id","is_default") WHERE "payment_methods"."is_default" = true; \ No newline at end of file diff --git a/apps/api/drizzle/0024_thankful_stick.sql b/apps/api/drizzle/0024_thankful_stick.sql deleted file mode 100644 index d7540626d5..0000000000 --- a/apps/api/drizzle/0024_thankful_stick.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "payment_methods" ADD COLUMN "is_default" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0023_snapshot.json b/apps/api/drizzle/meta/0023_snapshot.json index 9e28224fc0..6552bbc90c 100644 --- a/apps/api/drizzle/meta/0023_snapshot.json +++ b/apps/api/drizzle/meta/0023_snapshot.json @@ -1,5 +1,5 @@ { - "id": "c99448f1-eb44-4ee4-b25b-132521e40656", + "id": "f69cc97f-10fa-44c0-932f-5b33cd1c67fa", "prevId": "f58c7d88-f343-43f6-a7eb-ab36c3ad191c", "version": "7", "dialect": "postgresql", @@ -204,6 +204,13 @@ "notNull": true, "default": false }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -241,6 +248,28 @@ "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": [ diff --git a/apps/api/drizzle/meta/0024_snapshot.json b/apps/api/drizzle/meta/0024_snapshot.json deleted file mode 100644 index e9248074f1..0000000000 --- a/apps/api/drizzle/meta/0024_snapshot.json +++ /dev/null @@ -1,798 +0,0 @@ -{ - "id": "e4c9b4cc-05c4-4c5c-ada4-e1b8bd093dfb", - "prevId": "c99448f1-eb44-4ee4-b25b-132521e40656", - "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 - }, - "is_old_wallet": { - "name": "is_old_wallet", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": true - }, - "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": {}, - "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" - ] - } - } - }, - "public.checkout_sessions": { - "name": "checkout_sessions", - "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 - }, - "session_id": { - "name": "session_id", - "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()" - } - }, - "indexes": {}, - "foreignKeys": { - "checkout_sessions_user_id_userSetting_id_fk": { - "name": "checkout_sessions_user_id_userSetting_id_fk", - "tableFrom": "checkout_sessions", - "tableTo": "userSetting", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "checkout_sessions_session_id_unique": { - "name": "checkout_sessions_session_id_unique", - "nullsNotDistinct": false, - "columns": [ - "session_id" - ] - } - } - }, - "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_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": {} - }, - "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 - }, - "auto_reload_threshold": { - "name": "auto_reload_threshold", - "type": "numeric(20, 2)", - "primaryKey": false, - "notNull": false - }, - "auto_reload_amount": { - "name": "auto_reload_amount", - "type": "numeric(20, 2)", - "primaryKey": false, - "notNull": false - }, - "auto_reload_job_id": { - "name": "auto_reload_job_id", - "type": "uuid", - "primaryKey": false, - "notNull": 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" - ] - } - } - }, - "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" - ] - } - } - }, - "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" - ] - } - } - }, - "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" - ] - } - } - } - }, - "enums": {}, - "schemas": {}, - "_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 1627de00d5..5e0e71c640 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -166,15 +166,8 @@ { "idx": 23, "version": "7", - "when": 1764065989947, - "tag": "0023_sad_adam_warlock", - "breakpoints": true - }, - { - "idx": 24, - "version": "7", - "when": 1764152714000, - "tag": "0024_thankful_stick", + "when": 1764341427204, + "tag": "0023_steady_leech", "breakpoints": true } ] diff --git a/apps/api/src/billing/model-schemas/payment-method/payment-method.schema.ts b/apps/api/src/billing/model-schemas/payment-method/payment-method.schema.ts index 59898404f6..c1b76271bc 100644 --- a/apps/api/src/billing/model-schemas/payment-method/payment-method.schema.ts +++ b/apps/api/src/billing/model-schemas/payment-method/payment-method.schema.ts @@ -22,6 +22,9 @@ export const PaymentMethods = pgTable( }, table => ({ fingerprintPaymentMethodIdUnique: uniqueIndex("payment_methods_fingerprint_payment_method_id_unique").on(table.fingerprint, table.paymentMethodId), + userIdIsDefaultUnique: uniqueIndex("payment_methods_user_id_is_default_unique") + .on(table.userId, table.isDefault) + .where(sql`${table.isDefault} = true`), fingerprintIdx: index("payment_methods_fingerprint_idx").on(table.fingerprint), userIdIdx: index("payment_methods_user_id_idx").on(table.userId), userIdIsValidatedIdx: index("payment_methods_user_id_is_validated_idx").on(table.userId, table.isValidated), From 5a5e083775089877473d3e2fc3edfa37ec544519 Mon Sep 17 00:00:00 2001 From: Iaroslav Gryshaiev Date: Fri, 28 Nov 2025 16:57:59 +0100 Subject: [PATCH 4/9] feat(billing): adds default payment method get endpoint refs #1779 --- .../controllers/stripe/stripe.controller.ts | 16 ++++++++- .../src/billing/http-schemas/stripe.schema.ts | 5 +++ .../stripe-payment-methods.router.ts | 34 +++++++++++++++++-- .../stripe-webhook/stripe-webhook.router.ts | 1 + 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/apps/api/src/billing/controllers/stripe/stripe.controller.ts b/apps/api/src/billing/controllers/stripe/stripe.controller.ts index c4875c0588..ffdf49cd9f 100644 --- a/apps/api/src/billing/controllers/stripe/stripe.controller.ts +++ b/apps/api/src/billing/controllers/stripe/stripe.controller.ts @@ -5,7 +5,12 @@ import type { infer as ZodInfer } from "zod"; import { AuthService, Protected } from "@src/auth/services/auth.service"; import type { StripePricesOutputResponse } from "@src/billing"; -import { CustomerTransactionsCsvExportQuerySchema, PaymentMethodMarkAsDefaultInput, PaymentMethodsResponse } from "@src/billing/http-schemas/stripe.schema"; +import { + CustomerTransactionsCsvExportQuerySchema, + PaymentMethodMarkAsDefaultInput, + PaymentMethodResponse, + PaymentMethodsResponse +} from "@src/billing/http-schemas/stripe.schema"; import { ApplyCouponRequest, ConfirmPaymentRequest, @@ -50,6 +55,15 @@ export class StripeController { await this.stripe.markPaymentMethodAsDefault(input.data.id, currentUser, ability); } + @Protected([{ action: "read", subject: "StripePayment" }]) + async getDefaultPaymentMethod(): Promise { + const { ability } = this.authService; + const currentUser = this.authService.getCurrentPayingUser(); + const paymentMethod = await this.stripe.getDefaultPaymentMethod(currentUser, ability); + + return { data: paymentMethod }; + } + @Protected([{ action: "read", subject: "StripePayment" }]) async getPaymentMethods(): Promise { const currentUser = this.authService.getCurrentPayingUser({ strict: false }); diff --git a/apps/api/src/billing/http-schemas/stripe.schema.ts b/apps/api/src/billing/http-schemas/stripe.schema.ts index 21518d60a0..dd9ec50b64 100644 --- a/apps/api/src/billing/http-schemas/stripe.schema.ts +++ b/apps/api/src/billing/http-schemas/stripe.schema.ts @@ -58,6 +58,10 @@ export const PaymentMethodsResponseSchema = z.object({ data: z.array(PaymentMethodSchema) }); +export const PaymentMethodResponseSchema = z.object({ + data: PaymentMethodSchema +}); + export const ConfirmPaymentRequestSchema = z.object({ data: z.object({ userId: z.string(), @@ -236,6 +240,7 @@ export type SetupIntentResponse = z.infer; export type PaymentMethodMarkAsDefaultInput = z.infer; export type PaymentMethod = z.infer; export type PaymentMethodsResponse = z.infer; +export type PaymentMethodResponse = z.infer; export type ConfirmPaymentRequest = z.infer; export type PaymentIntentResult = z.infer; export type PaymentMethodValidationResult = z.infer; diff --git a/apps/api/src/billing/routes/stripe-payment-methods/stripe-payment-methods.router.ts b/apps/api/src/billing/routes/stripe-payment-methods/stripe-payment-methods.router.ts index c27c9fa8a9..4665d3af78 100644 --- a/apps/api/src/billing/routes/stripe-payment-methods/stripe-payment-methods.router.ts +++ b/apps/api/src/billing/routes/stripe-payment-methods/stripe-payment-methods.router.ts @@ -3,6 +3,7 @@ import { container } from "tsyringe"; import { StripeController } from "@src/billing/controllers/stripe/stripe.controller"; import { PaymentMethodMarkAsDefaultInputSchema, + PaymentMethodResponseSchema, PaymentMethodsResponseSchema, RemovePaymentMethodParamsSchema, SetupIntentResponseSchema, @@ -41,7 +42,7 @@ stripePaymentMethodsRouter.openapi(setupIntentRoute, async function createSetupI return c.json(response, 200); }); -const updateRoute = createRoute({ +const markAsDefaultRoute = createRoute({ method: "post", path: `/v1/stripe/payment-methods/default`, summary: "Marks a payment method as the default.", @@ -63,11 +64,40 @@ const updateRoute = createRoute({ } }); -stripePaymentMethodsRouter.openapi(updateRoute, async function markAsDefault(c) { +stripePaymentMethodsRouter.openapi(markAsDefaultRoute, async function markAsDefault(c) { await container.resolve(StripeController).markAsDefault(c.req.valid("json")); return c.json(undefined, 200); }); +const getDefaultPaymentMethodRoute = createRoute({ + method: "get", + path: "/v1/stripe/payment-methods/default", + summary: "Get the default payment method for the current user", + description: + "Retrieves the default payment method associated with the current user's account, including card details, validation status, and billing information.", + tags: ["Payment"], + security: SECURITY_BEARER_OR_API_KEY, + request: {}, + responses: { + 200: { + description: "Default payment method retrieved successfully", + content: { + "application/json": { + schema: PaymentMethodResponseSchema + } + } + }, + 404: { + description: "Default payment method not found" + } + } +}); + +stripePaymentMethodsRouter.openapi(getDefaultPaymentMethodRoute, async function getDefaultPaymentMethod(c) { + const response = await container.resolve(StripeController).getDefaultPaymentMethod(); + return c.json(response, 200); +}); + const paymentMethodsRoute = createRoute({ method: "get", path: "/v1/stripe/payment-methods", diff --git a/apps/api/src/billing/routes/stripe-webhook/stripe-webhook.router.ts b/apps/api/src/billing/routes/stripe-webhook/stripe-webhook.router.ts index 1982e27870..70564f4372 100644 --- a/apps/api/src/billing/routes/stripe-webhook/stripe-webhook.router.ts +++ b/apps/api/src/billing/routes/stripe-webhook/stripe-webhook.router.ts @@ -40,6 +40,7 @@ const route = createRoute({ } } }); + stripeWebhook.openapi(route, async function routeStripeWebhook(c) { const sig = c.req.header("stripe-signature"); if (!sig) { From f99759f9811973ef467503b17f733e87d489cefd Mon Sep 17 00:00:00 2001 From: Iaroslav Gryshaiev Date: Tue, 2 Dec 2025 14:50:31 +0100 Subject: [PATCH 5/9] feat(billing): add automatic wallet balance reload for auto top-up Implement daily wallet balance checks that automatically reload funds when balance falls below 25% of 7-day deployment costs for auto top-up enabled deployments. Includes immediate triggers on feature enable, deployment creation, and deposits. Uses new WalletReloadJobService to manage job scheduling and break dependency cycles. refs #1779 --- ...eady_leech.sql => 0023_clumsy_vertigo.sql} | 4 +- apps/api/drizzle/meta/0023_snapshot.json | 14 +- apps/api/drizzle/meta/_journal.json | 4 +- .../auth/services/ability/ability.service.ts | 2 + .../controllers/stripe/stripe.controller.ts | 8 +- .../wallet-setting/wallet-setting.schema.ts | 10 +- .../wallet-settings.repository.ts | 30 +- .../services/balances/balances.service.ts | 20 +- .../managed-signer.service.spec.ts | 105 ++++- .../managed-signer/managed-signer.service.ts | 17 +- .../billing/services/stripe/stripe.service.ts | 9 +- .../wallet-balance-reload-check/README.md | 183 +++++++++ ...allet-balance-reload-check.handler.spec.ts | 360 +++++++++++------- .../wallet-balance-reload-check.handler.ts | 182 ++++----- .../wallet-reload-job.service.ts | 59 +++ .../wallet-settings.service.spec.ts | 60 ++- .../wallet-settings.service.ts | 34 +- .../job-queue/job-queue.service.spec.ts | 3 +- .../services/job-queue/job-queue.service.ts | 14 +- .../dashboard/services/stats/stats.service.ts | 5 + .../deployment-setting.repository.ts | 6 +- .../draining-deployment.service.spec.ts | 317 +++++++++++---- .../draining-deployment.service.ts | 48 +++ .../__snapshots__/docs.spec.ts.snap | 152 ++++++++ 24 files changed, 1174 insertions(+), 472 deletions(-) rename apps/api/drizzle/{0023_steady_leech.sql => 0023_clumsy_vertigo.sql} (56%) create mode 100644 apps/api/src/billing/services/wallet-balance-reload-check/README.md create mode 100644 apps/api/src/billing/services/wallet-reload-job/wallet-reload-job.service.ts diff --git a/apps/api/drizzle/0023_steady_leech.sql b/apps/api/drizzle/0023_clumsy_vertigo.sql similarity index 56% rename from apps/api/drizzle/0023_steady_leech.sql rename to apps/api/drizzle/0023_clumsy_vertigo.sql index 0ad920e767..44a3f8b162 100644 --- a/apps/api/drizzle/0023_steady_leech.sql +++ b/apps/api/drizzle/0023_clumsy_vertigo.sql @@ -1,3 +1,5 @@ ALTER TABLE "user_wallets" ALTER COLUMN "user_id" SET NOT NULL;--> statement-breakpoint ALTER TABLE "payment_methods" ADD COLUMN "is_default" boolean DEFAULT false NOT NULL;--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "payment_methods_user_id_is_default_unique" ON "payment_methods" USING btree ("user_id","is_default") WHERE "payment_methods"."is_default" = true; \ No newline at end of file +CREATE UNIQUE INDEX IF NOT EXISTS "payment_methods_user_id_is_default_unique" ON "payment_methods" USING btree ("user_id","is_default") WHERE "payment_methods"."is_default" = true;--> statement-breakpoint +ALTER TABLE "wallet_settings" DROP COLUMN IF EXISTS "auto_reload_threshold";--> statement-breakpoint +ALTER TABLE "wallet_settings" DROP COLUMN IF EXISTS "auto_reload_amount"; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0023_snapshot.json b/apps/api/drizzle/meta/0023_snapshot.json index 6552bbc90c..c3d59ff759 100644 --- a/apps/api/drizzle/meta/0023_snapshot.json +++ b/apps/api/drizzle/meta/0023_snapshot.json @@ -1,5 +1,5 @@ { - "id": "f69cc97f-10fa-44c0-932f-5b33cd1c67fa", + "id": "f7545314-c294-4889-b79e-89b21973f7f0", "prevId": "f58c7d88-f343-43f6-a7eb-ab36c3ad191c", "version": "7", "dialect": "postgresql", @@ -397,18 +397,6 @@ "notNull": true, "default": false }, - "auto_reload_threshold": { - "name": "auto_reload_threshold", - "type": "numeric(20, 2)", - "primaryKey": false, - "notNull": false - }, - "auto_reload_amount": { - "name": "auto_reload_amount", - "type": "numeric(20, 2)", - "primaryKey": false, - "notNull": false - }, "auto_reload_job_id": { "name": "auto_reload_job_id", "type": "uuid", diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 5e0e71c640..d520051500 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -166,8 +166,8 @@ { "idx": 23, "version": "7", - "when": 1764341427204, - "tag": "0023_steady_leech", + "when": 1764597182108, + "tag": "0023_clumsy_vertigo", "breakpoints": true } ] diff --git a/apps/api/src/auth/services/ability/ability.service.ts b/apps/api/src/auth/services/ability/ability.service.ts index deb2b9d6a2..7e41260cb3 100644 --- a/apps/api/src/auth/services/ability/ability.service.ts +++ b/apps/api/src/auth/services/ability/ability.service.ts @@ -27,6 +27,7 @@ export class AbilityService { { action: "read", subject: "User", conditions: { id: "${user.id}" } }, { action: "verify-email", subject: "User", conditions: { email: "${user.email}" } }, { action: ["create", "read", "delete"], subject: "StripePayment" }, + { action: "manage", subject: "PaymentMethod", conditions: { userId: "${user.id}" } }, { action: "create", subject: "VerificationEmail", conditions: { id: "${user.id}" } }, { action: "manage", subject: "DeploymentSetting", conditions: { userId: "${user.id}" } }, { action: "manage", subject: "Alert", conditions: { userId: "${user.id}" } }, @@ -38,6 +39,7 @@ export class AbilityService { { action: "read", subject: "User", conditions: { id: "${user.id}" } }, { action: "verify-email", subject: "User", conditions: { email: "${user.email}" } }, { action: ["create", "read", "delete"], subject: "StripePayment" }, + { action: "manage", subject: "PaymentMethod", conditions: { userId: "${user.id}" } }, { action: "create", subject: "VerificationEmail", conditions: { id: "${user.id}" } }, { action: "manage", subject: "DeploymentSetting", conditions: { userId: "${user.id}" } }, { action: "manage", subject: "ApiKey", conditions: { userId: "${user.id}" } }, diff --git a/apps/api/src/billing/controllers/stripe/stripe.controller.ts b/apps/api/src/billing/controllers/stripe/stripe.controller.ts index ffdf49cd9f..2afe749cb9 100644 --- a/apps/api/src/billing/controllers/stripe/stripe.controller.ts +++ b/apps/api/src/billing/controllers/stripe/stripe.controller.ts @@ -47,7 +47,7 @@ export class StripeController { return { data: { clientSecret: setupIntent.client_secret } }; } - @Protected([{ action: "update", subject: "StripePayment" }]) + @Protected([{ action: "update", subject: "PaymentMethod" }]) async markAsDefault(input: PaymentMethodMarkAsDefaultInput): Promise { const { ability } = this.authService; const currentUser = this.authService.getCurrentPayingUser(); @@ -55,16 +55,18 @@ export class StripeController { await this.stripe.markPaymentMethodAsDefault(input.data.id, currentUser, ability); } - @Protected([{ action: "read", subject: "StripePayment" }]) + @Protected([{ action: "read", subject: "PaymentMethod" }]) async getDefaultPaymentMethod(): Promise { const { ability } = this.authService; const currentUser = this.authService.getCurrentPayingUser(); const paymentMethod = await this.stripe.getDefaultPaymentMethod(currentUser, ability); + assert(paymentMethod, 404, "PaymentMethod not found"); + return { data: paymentMethod }; } - @Protected([{ action: "read", subject: "StripePayment" }]) + @Protected([{ action: "read", subject: "PaymentMethod" }]) async getPaymentMethods(): Promise { const currentUser = this.authService.getCurrentPayingUser({ strict: false }); diff --git a/apps/api/src/billing/model-schemas/wallet-setting/wallet-setting.schema.ts b/apps/api/src/billing/model-schemas/wallet-setting/wallet-setting.schema.ts index 378a0cdba9..df21c03021 100644 --- a/apps/api/src/billing/model-schemas/wallet-setting/wallet-setting.schema.ts +++ b/apps/api/src/billing/model-schemas/wallet-setting/wallet-setting.schema.ts @@ -1,5 +1,5 @@ import { relations, sql } from "drizzle-orm"; -import { boolean, index, integer, numeric, pgTable, timestamp, unique, uuid } from "drizzle-orm/pg-core"; +import { boolean, index, integer, pgTable, timestamp, unique, uuid } from "drizzle-orm/pg-core"; import { UserWallets } from "@src/billing/model-schemas/user-wallet/user-wallet.schema"; import { Users } from "@src/user/model-schemas"; @@ -18,14 +18,6 @@ export const WalletSetting = pgTable( .references(() => Users.id, { onDelete: "cascade" }) .notNull(), autoReloadEnabled: boolean("auto_reload_enabled").default(false).notNull(), - autoReloadThreshold: numeric("auto_reload_threshold", { - precision: 20, - scale: 2 - }), - autoReloadAmount: numeric("auto_reload_amount", { - precision: 20, - scale: 2 - }), autoReloadJobId: uuid("auto_reload_job_id"), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow() diff --git a/apps/api/src/billing/repositories/wallet-settings/wallet-settings.repository.ts b/apps/api/src/billing/repositories/wallet-settings/wallet-settings.repository.ts index 249e36c867..82f45c4a38 100644 --- a/apps/api/src/billing/repositories/wallet-settings/wallet-settings.repository.ts +++ b/apps/api/src/billing/repositories/wallet-settings/wallet-settings.repository.ts @@ -61,34 +61,6 @@ export class WalletSettingRepository extends BaseRepository): WalletSettingOutput { - const output = dbOutput as DbWalletSettingOutput; - return { - ...output, - autoReloadThreshold: output.autoReloadThreshold === null ? undefined : parseFloat(output.autoReloadThreshold), - autoReloadAmount: output.autoReloadAmount === null ? undefined : parseFloat(output.autoReloadAmount) - } as WalletSettingOutput; - } - - protected toInput(payload: Partial): Partial { - const { autoReloadThreshold, autoReloadAmount, ...input } = payload; - const dbInput: Partial = input as Partial; - - if (autoReloadThreshold !== undefined) { - dbInput.autoReloadThreshold = autoReloadThreshold.toString(); - } - - if (autoReloadAmount !== undefined) { - dbInput.autoReloadAmount = autoReloadAmount.toString(); - } - - return dbInput; + return walletSetting; } } diff --git a/apps/api/src/billing/services/balances/balances.service.ts b/apps/api/src/billing/services/balances/balances.service.ts index 8a76280a7a..0bd8b18cf1 100644 --- a/apps/api/src/billing/services/balances/balances.service.ts +++ b/apps/api/src/billing/services/balances/balances.service.ts @@ -111,21 +111,25 @@ export class BalancesService { @Memoize({ ttlInSeconds: averageBlockTime }) async getFullBalanceInFiat(address: string, isOldWallet: boolean = false): Promise { - const coin = this.config.DEPLOYMENT_GRANT_DENOM === "uakt" ? "akash-network" : "usd-coin"; - const [fullBalance, stats] = await Promise.all([this.getFullBalance(address, isOldWallet), this.statsService.getMarketData(coin)]); + const { data } = await this.getFullBalance(address, isOldWallet); - const balance = this.#toFiatAmount(fullBalance.data.balance * stats.price); - const deployments = this.#toFiatAmount(fullBalance.data.deployments * stats.price); - const total = this.#formatFiatAmount(balance + deployments); + const balance = await this.toFiatAmount(data.balance); + const deployments = await this.toFiatAmount(data.deployments); + const total = this.ensure2floatingDigits(balance + deployments); return { balance, deployments, total }; } - #toFiatAmount(uTokenAmount: number) { - return this.#formatFiatAmount(uTokenAmount / 1_000_000); + async toFiatAmount(uTokenAmount: number) { + return this.ensure2floatingDigits(await this.#convertToFiatAmount(uTokenAmount / 1_000_000)); + } + + async #convertToFiatAmount(amount: number): Promise { + const coin = this.config.DEPLOYMENT_GRANT_DENOM === "uakt" ? "akash-network" : "usd-coin"; + return await this.statsService.convertToFiatAmount(amount, coin); } - #formatFiatAmount(amount: number) { + ensure2floatingDigits(amount: number) { return parseFloat(amount.toFixed(2)); } } diff --git a/apps/api/src/billing/services/managed-signer/managed-signer.service.spec.ts b/apps/api/src/billing/services/managed-signer/managed-signer.service.spec.ts index 2774f20e6e..4aa062c161 100644 --- a/apps/api/src/billing/services/managed-signer/managed-signer.service.spec.ts +++ b/apps/api/src/billing/services/managed-signer/managed-signer.service.spec.ts @@ -1,3 +1,4 @@ +import { MsgAccountDeposit } from "@akashnetwork/chain-sdk/private-types/akash.v1"; import { MsgCreateDeployment } from "@akashnetwork/chain-sdk/private-types/akash.v1beta4"; import { MsgCreateLease } from "@akashnetwork/chain-sdk/private-types/akash.v1beta5"; import type { LeaseHttpService } from "@akashnetwork/http-sdk"; @@ -13,6 +14,7 @@ import type { UserWalletRepository } from "@src/billing/repositories"; import type { BalancesService } from "@src/billing/services/balances/balances.service"; import type { ChainErrorService } from "@src/billing/services/chain-error/chain-error.service"; import type { TrialValidationService } from "@src/billing/services/trial-validation/trial-validation.service"; +import type { WalletReloadJobService } from "@src/billing/services/wallet-reload-job/wallet-reload-job.service"; import type { DomainEventsService } from "@src/core/services/domain-events/domain-events.service"; import type { FeatureFlagValue } from "@src/core/services/feature-flags/feature-flags"; import { FeatureFlags } from "@src/core/services/feature-flags/feature-flags"; @@ -487,6 +489,94 @@ describe(ManagedSignerService.name, () => { }); }); + describe("executeDerivedEncodedTxByUserId", () => { + it("executes transaction and calls scheduleImmediate when transaction contains MsgCreateDeployment", async () => { + const wallet = UserWalletSeeder.create({ + userId: "user-123", + feeAllowance: 100, + deploymentAllowance: 100 + }); + const user = UserSeeder.create({ userId: "user-123" }); + const deploymentMessage = { + typeUrl: MsgCreateDeployment.$type, + value: Buffer.from(JSON.stringify({ id: { dseq: "123", owner: wallet.address } })).toString("base64") + }; + + const { service, walletReloadJobService } = setup({ + findOneByUserId: jest.fn().mockResolvedValue(wallet), + findById: jest.fn().mockResolvedValue(user), + signAndBroadcastWithDerivedWallet: jest.fn().mockResolvedValue({ + code: 0, + hash: "tx-hash", + rawLog: "success" + }), + refreshUserWalletLimits: jest.fn().mockResolvedValue(undefined), + decode: jest.fn().mockReturnValue({ id: { dseq: "123", owner: wallet.address } }) + }); + + await service.executeDerivedEncodedTxByUserId("user-123", [deploymentMessage]); + + expect(walletReloadJobService.scheduleImmediate).toHaveBeenCalledWith("user-123"); + }); + + it("executes transaction and calls scheduleImmediate when transaction contains MsgAccountDeposit", async () => { + const wallet = UserWalletSeeder.create({ + userId: "user-123", + feeAllowance: 100 + }); + const user = UserSeeder.create({ userId: "user-123" }); + const depositMessage = { + typeUrl: MsgAccountDeposit.$type, + value: Buffer.from(JSON.stringify({ owner: wallet.address, amount: "1000" })).toString("base64") + }; + + const { service, walletReloadJobService } = setup({ + findOneByUserId: jest.fn().mockResolvedValue(wallet), + findById: jest.fn().mockResolvedValue(user), + signAndBroadcastWithDerivedWallet: jest.fn().mockResolvedValue({ + code: 0, + hash: "tx-hash", + rawLog: "success" + }), + refreshUserWalletLimits: jest.fn().mockResolvedValue(undefined), + decode: jest.fn().mockReturnValue({ owner: wallet.address, amount: "1000" }) + }); + + await service.executeDerivedEncodedTxByUserId("user-123", [depositMessage]); + + expect(walletReloadJobService.scheduleImmediate).toHaveBeenCalledWith("user-123"); + }); + + it("executes transaction and does not call scheduleImmediate when transaction does not contain spending messages", async () => { + const wallet = UserWalletSeeder.create({ + userId: "user-123", + feeAllowance: 100, + deploymentAllowance: 100 + }); + const user = UserSeeder.create({ userId: "user-123" }); + const leaseMessage = { + typeUrl: MsgCreateLease.$type, + value: Buffer.from(JSON.stringify({ bidId: { dseq: "123" } })).toString("base64") + }; + + const { service, walletReloadJobService } = setup({ + findOneByUserId: jest.fn().mockResolvedValue(wallet), + findById: jest.fn().mockResolvedValue(user), + signAndBroadcastWithDerivedWallet: jest.fn().mockResolvedValue({ + code: 0, + hash: "tx-hash", + rawLog: "success" + }), + refreshUserWalletLimits: jest.fn().mockResolvedValue(undefined), + decode: jest.fn().mockReturnValue({ bidId: { dseq: "123" } }) + }); + + await service.executeDerivedEncodedTxByUserId("user-123", [leaseMessage]); + + expect(walletReloadJobService.scheduleImmediate).not.toHaveBeenCalled(); + }); + }); + function setup(input?: { findOneByUserId?: UserWalletRepository["findOneByUserId"]; findById?: UserRepository["findById"]; @@ -500,6 +590,7 @@ describe(ManagedSignerService.name, () => { publish?: DomainEventsService["publish"]; transformChainError?: ChainErrorService["toAppError"]; hasLeases?: LeaseHttpService["hasLeases"]; + decode?: Registry["decode"]; }) { const mocks = { userWalletRepository: mock({ @@ -536,11 +627,18 @@ describe(ManagedSignerService.name, () => { }), leaseHttpService: mock({ hasLeases: input?.hasLeases ?? jest.fn(async () => false) + }), + walletReloadJobService: mock({ + scheduleImmediate: jest.fn() }) }; + const registryMock = mock({ + decode: input?.decode ?? jest.fn() + }); + const service = new ManagedSignerService( - mock(), + registryMock, mocks.userWalletRepository, mocks.userRepository, mocks.balancesService, @@ -550,9 +648,10 @@ describe(ManagedSignerService.name, () => { mocks.featureFlagsService, mocks.txManagerService, mocks.domainEvents, - mocks.leaseHttpService + mocks.leaseHttpService, + mocks.walletReloadJobService ); - return { service, ...mocks }; + return { service, registry: registryMock, ...mocks }; } }); diff --git a/apps/api/src/billing/services/managed-signer/managed-signer.service.ts b/apps/api/src/billing/services/managed-signer/managed-signer.service.ts index 7d2125c145..d06d8c507c 100644 --- a/apps/api/src/billing/services/managed-signer/managed-signer.service.ts +++ b/apps/api/src/billing/services/managed-signer/managed-signer.service.ts @@ -1,3 +1,5 @@ +import { MsgAccountDeposit } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { MsgCreateDeployment } from "@akashnetwork/chain-sdk/private-types/akash.v1beta4"; import { MsgCreateLease } from "@akashnetwork/chain-sdk/private-types/akash.v1beta5"; import { LeaseHttpService } from "@akashnetwork/http-sdk"; import { EncodeObject, Registry } from "@cosmjs/proto-signing"; @@ -11,6 +13,7 @@ import { TrialDeploymentLeaseCreated } from "@src/billing/events/trial-deploymen import { InjectTypeRegistry } from "@src/billing/providers/type-registry.provider"; import { UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; import { TxManagerService } from "@src/billing/services/tx-manager/tx-manager.service"; +import { WalletReloadJobService } from "@src/billing/services/wallet-reload-job/wallet-reload-job.service"; import { DomainEventsService } from "@src/core/services/domain-events/domain-events.service"; import { FeatureFlags } from "@src/core/services/feature-flags/feature-flags"; import { FeatureFlagsService } from "@src/core/services/feature-flags/feature-flags.service"; @@ -34,7 +37,8 @@ export class ManagedSignerService { private readonly featureFlagsService: FeatureFlagsService, private readonly txManagerService: TxManagerService, private readonly domainEvents: DomainEventsService, - private readonly leaseHttpService: LeaseHttpService + private readonly leaseHttpService: LeaseHttpService, + private readonly walletReloadJobService: WalletReloadJobService ) {} async executeDerivedTx(walletIndex: number, messages: readonly EncodeObject[], useOldWallet: boolean = false) { @@ -62,7 +66,16 @@ export class ManagedSignerService { } async executeDerivedEncodedTxByUserId(userId: UserWalletOutput["userId"], messages: StringifiedEncodeObject[]) { - return this.executeDerivedDecodedTxByUserId(userId, this.decodeMessages(messages)); + const decoded = this.decodeMessages(messages); + const result = await this.executeDerivedDecodedTxByUserId(userId, decoded); + + const hasSpendingTx = decoded.some(message => message.typeUrl.endsWith(MsgCreateDeployment.$type) || message.typeUrl.endsWith(MsgAccountDeposit.$type)); + + if (hasSpendingTx) { + await this.walletReloadJobService.scheduleImmediate(userId); + } + + return result; } async executeDerivedDecodedTxByUserId( diff --git a/apps/api/src/billing/services/stripe/stripe.service.ts b/apps/api/src/billing/services/stripe/stripe.service.ts index 26ff7534b8..425956f52a 100644 --- a/apps/api/src/billing/services/stripe/stripe.service.ts +++ b/apps/api/src/billing/services/stripe/stripe.service.ts @@ -119,7 +119,7 @@ export class StripeService extends Stripe { .sort((a, b) => b.created - a.created); } - async getDefaultPaymentMethod(user: PayingUser, ability: AnyAbility): Promise { + async getDefaultPaymentMethod(user: PayingUser, ability: AnyAbility): Promise { const [customer, local] = await Promise.all([ this.customers.retrieve(user.stripeCustomerId, { expand: ["invoice_settings.default_payment_method"] @@ -131,10 +131,9 @@ export class StripeService extends Stripe { const remote = customer.invoice_settings.default_payment_method as Stripe.PaymentMethod; - assert(local, 404, "Default payment method not found", { source: "database" }); - assert(remote, 404, "Default payment method not found", { source: "stripe" }); - - return { ...remote, validated: local.isValidated }; + if (remote && local) { + return { ...remote, validated: local.isValidated }; + } } async hasPaymentMethod(paymentMethodId: string, user: UserOutput): Promise { diff --git a/apps/api/src/billing/services/wallet-balance-reload-check/README.md b/apps/api/src/billing/services/wallet-balance-reload-check/README.md new file mode 100644 index 0000000000..0ada2e39e2 --- /dev/null +++ b/apps/api/src/billing/services/wallet-balance-reload-check/README.md @@ -0,0 +1,183 @@ +# WalletBalanceReloadCheckHandler - Logic Explanation + +## Overview + +This job worker automatically reloads user wallet balances when they're running low relative to projected deployment costs for deployments with auto top-up enabled. It runs daily and proactively reloads funds when the balance can only cover less than 25% of the next 7 days of deployment costs. + +## Core Logic + +### The Problem + +Users need sufficient funds to keep their deployments with auto top-up enabled running. Without auto-reload, deployments would stop when funds run out, requiring manual intervention. + +### The Solution + +1. **Calculate** how much money is needed to keep all deployments with auto top-up enabled running for 7 days +2. **Compare** current balance with 25% of that cost (threshold check) +3. **Reload** if balance is below threshold (with a $20 minimum) +4. **Schedule** the next check for 24 hours from now + +### Key Design Decisions + +**Why check daily?** + +- Daily checks allow proactive reloading before funds run critically low +- Catches issues quickly if deployment costs spike +- Balances responsiveness with system load + +**Why calculate costs for 7 days?** + +- Provides a meaningful projection window for deployment costs +- Ensures reloads cover a full week of operations for deployments with auto top-up enabled +- Balances UX (users don't get charged too frequently) with transaction costs +- Reduces the number of payment transactions while maintaining adequate coverage + +**Why reload at 25% threshold?** + +- Reloads when balance can only cover less than ~1.75 days (25% of 7 days) +- Provides a safety margin before funds run out +- Prevents emergency situations + +## When Does the Check Run? + +The handler runs in three scenarios: + +1. **When feature is enabled**: Immediately when a user enables auto-reload +2. **Scheduled checks**: Every 24 hours (1 day) for users with auto-reload enabled +3. **Immediate triggers**: When a user creates a deployment or makes a deposit + +When auto-reload is enabled, the first check runs immediately. Subsequent checks run on a daily schedule. Immediate triggers (deployments/deposits) ensure the balance is checked right after spending or depositing funds, rather than waiting for the next scheduled check. + +## Sequence Diagram + +``` +User enables auto-reload + │ + ▼ +WalletSettingService schedules immediate check + │ + ├─► [OR] User creates deployment / makes deposit + │ │ + │ ▼ + │ ManagedSignerService.scheduleImmediate() + │ │ + │ ▼ + │ WalletSettingService cancels existing job + │ │ + │ ▼ + │ WalletSettingService enqueues immediate check + │ + ▼ +[Immediately when enabled, OR 24 hours later for scheduled checks, OR immediately on deployment/deposit] + │ + ▼ +WalletBalanceReloadCheckHandler.handle() + │ + ├─► Collect Resources + │ ├─► Get wallet setting (verify auto-reload enabled) + │ ├─► Get user wallet (verify initialized) + │ ├─► Get user (verify Stripe customer ID) + │ ├─► Get default payment method + │ ├─► Get current balance (in USD) + │ └─► Calculate cost for 7 days ahead (deployments with auto top-up enabled) + │ + ├─► Try to Reload + │ ├─► Compare: balance >= 25% of 7-day cost? + │ │ ├─► YES: Skip reload, log "RELOAD_SKIPPED" + │ │ └─► NO: Continue (balance can only cover < ~1.75 days) + │ │ + │ ├─► Calculate reload amount + │ │ └─► max(7-day-cost - balance, $20) + │ │ + │ └─► Create Stripe payment intent + │ └─► Charge user's default payment method + │ + └─► Schedule Next Check + ├─► Enqueue job for 24 hours from now + └─► Update wallet setting with new job ID +``` + +## Reload Threshold Logic + +The handler reloads when: + +``` +balance < 0.25 * costUntilTargetDateInFiat +``` + +This means: reload when balance can only cover less than 25% of the 7-day cost projection (~1.75 days). + +**Example scenarios:** + +1. **Balance: $10, 7-day Cost: $40** + + - 25% threshold: $10 + - Balance ($10) >= threshold ($10) → **Skip reload** (exactly at threshold) + +2. **Balance: $9, 7-day Cost: $40** + + - 25% threshold: $10 + - Balance ($9) < threshold ($10) → **Reload** + - Reload amount: max($40 - $9, $20) = $31 + +3. **Balance: $5, 7-day Cost: $20** + + - 25% threshold: $5 + - Balance ($5) >= threshold ($5) → **Skip reload** (exactly at threshold) + +4. **Balance: $4, 7-day Cost: $20** + - 25% threshold: $5 + - Balance ($4) < threshold ($5) → **Reload** + - Reload amount: max($20 - $4, $20) = $20 (minimum applies) + +## Cost Calculation + +The handler calculates the total cost needed to keep all active deployments with auto top-up enabled running for 7 days: + +1. Gets all auto-top-up deployments for the user's wallet +2. For each deployment: + - Finds when it would close (predicted closed height) + - Calculates blocks needed from closure to target date (7 days from now) + - Multiplies by block rate to get cost +3. Sums all costs to get total 7-day cost + +**Note**: Only deployments with auto top-up enabled are considered in the cost calculation. + +**Target date**: 7 days from now (`RELOAD_COVERAGE_PERIOD_IN_MS`) + +## Reload Amount Calculation + +``` +reloadAmount = max(costUntilTargetDateInFiat - balance, $20) +``` + +The reload amount ensures the balance can cover the full 7-day cost projection, with a $20 minimum to prevent tiny charges and meet Stripe's requirements. + +## Validation Flow + +Before processing, the handler validates: + +1. ✅ Wallet setting exists +2. ✅ Auto-reload is enabled +3. ✅ Wallet is initialized (has address) +4. ✅ User has Stripe customer ID +5. ✅ Default payment method exists + +If any validation fails, the handler logs an error and skips processing (doesn't throw). + +## Key Constants + +- **Check Interval**: 24 hours (1 day) - how often the job runs +- **Reload Coverage Period**: 7 days - period for which costs are calculated +- **Minimum Coverage Percentage**: 25% - triggers reload when balance falls below this percentage of 7-day cost +- **Minimum Reload**: $20 USD - prevents tiny charges + +**Note**: These constants can be fine-tuned based on real-life UX data and user feedback to optimize the balance between user experience, transaction frequency, and system efficiency. + +## What Happens on Failure? + +- **Payment fails**: Error is logged and re-thrown (job fails, will retry) +- **Validation fails**: Error is logged, job completes successfully (no retry needed) +- **Job ID update fails**: Error is logged, job completes (next check still scheduled) + +**Observability**: Observability is configured to alert on any issues when failures happen. Even when the job ends successfully on validation errors, alerts are triggered so the team can react and ensure issues are fixed promptly. diff --git a/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.spec.ts b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.spec.ts index 5e34d357ce..d3ccde1031 100644 --- a/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.spec.ts +++ b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.spec.ts @@ -1,12 +1,14 @@ import { faker } from "@faker-js/faker"; -import createHttpError from "http-errors"; +import { addMilliseconds, millisecondsInHour } from "date-fns"; import { mock } from "jest-mock-extended"; import { WalletBalanceReloadCheck } from "@src/billing/events/wallet-balance-reload-check"; import type { WalletSettingRepository } from "@src/billing/repositories"; import type { BalancesService } from "@src/billing/services/balances/balances.service"; import type { StripeService } from "@src/billing/services/stripe/stripe.service"; -import type { JobMeta, JobQueueService, LoggerService } from "@src/core"; +import type { WalletReloadJobService } from "@src/billing/services/wallet-reload-job/wallet-reload-job.service"; +import type { JobMeta, LoggerService } from "@src/core"; +import type { DrainingDeploymentService } from "@src/deployment/services/draining-deployment/draining-deployment.service"; import type { JobPayload } from "../../../core"; import { WalletBalanceReloadCheckHandler } from "./wallet-balance-reload-check.handler"; @@ -18,34 +20,44 @@ import { generateWalletSetting } from "@test/seeders/wallet-setting.seeder"; describe(WalletBalanceReloadCheckHandler.name, () => { describe("handle", () => { - it("triggers reload when balance is below threshold", async () => { - const { - handler, - walletSettingRepository, - balancesService, - stripeService, - jobQueueService, - loggerService, - walletSettingWithWallet, - walletSetting, - wallet, - job, - jobMeta - } = setup(); - const paymentMethod = generatePaymentMethod(); - const balance = generateBalance({ balance: 15.0, deployments: 0, total: 15.0 }); - walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(walletSettingWithWallet); - balancesService.getFullBalanceInFiat.mockResolvedValue(balance); - stripeService.getDefaultPaymentMethod.mockResolvedValue(paymentMethod); - jobQueueService.enqueue.mockResolvedValue(faker.string.uuid()); + it("triggers reload when balance is below 25% of cost", async () => { + // Given: balance = $10, costUntilTargetDate = $50 + // Expected: 25% threshold = $12.50, balance ($10) < threshold → reload + // Expected: reload amount = max($50 - $10, $20) = $40 + // Expected: calculates cost for 7 days, schedules next check in 1 day + const balance = 10.0; + const costUntilTargetDateInDenom = 50_000_000; // 50 USD in udenom + const costUntilTargetDateInFiat = 50.0; + const expectedReloadAmount = 40.0; // max(50 - 10, 20) = 40 + + const { handler, drainingDeploymentService, stripeService, loggerService, walletReloadJobService, job, jobMeta } = setup({ + balance: { total: balance }, + weeklyCostInDenom: costUntilTargetDateInDenom, + weeklyCostInFiat: costUntilTargetDateInFiat + }); await handler.handle(job, jobMeta); - expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); + // Verify calculateAllDeploymentCostUntilDate is called with 7 days + expect(drainingDeploymentService.calculateAllDeploymentCostUntilDate).toHaveBeenCalled(); + const calculateCall = drainingDeploymentService.calculateAllDeploymentCostUntilDate.mock.calls[0]; + const reloadTargetDate = calculateCall[1]; + const millisecondsInDay = 24 * millisecondsInHour; + const expectedReloadDate = addMilliseconds(new Date(), 7 * millisecondsInDay); + expect(reloadTargetDate.getTime()).toBeCloseTo(expectedReloadDate.getTime(), -3); + + // Verify next check is scheduled for 1 day + expect(walletReloadJobService.scheduleForWalletSetting).toHaveBeenCalled(); + const scheduleCall = walletReloadJobService.scheduleForWalletSetting.mock.calls[0]; + const scheduledDate = scheduleCall[1]?.startAfter; + expect(scheduledDate).toBeInstanceOf(Date); + const expectedNextCheckDate = addMilliseconds(new Date(), millisecondsInDay); + expect((scheduledDate as Date).getTime()).toBeCloseTo(expectedNextCheckDate.getTime(), -3); + expect(stripeService.createPaymentIntent).toHaveBeenCalledWith({ - customer: walletSettingWithWallet.user.stripeCustomerId, - payment_method: paymentMethod.id, - amount: walletSetting.autoReloadAmount, + customer: expect.any(String), + payment_method: expect.any(String), + amount: expectedReloadAmount, currency: "usd", confirm: true, idempotencyKey: `${WalletBalanceReloadCheck.name}.${jobMeta.id}` @@ -53,102 +65,148 @@ describe(WalletBalanceReloadCheckHandler.name, () => { expect(loggerService.info).toHaveBeenCalledWith( expect.objectContaining({ event: "WALLET_BALANCE_RELOADED", - walletAddress: wallet.address, - balance: 15.0, - threshold: 30.0, - amount: 100.0 + balance, + costUntilTargetDateInFiat }) ); }); - it("triggers reload when balance equals threshold", async () => { - const { handler, walletSettingRepository, balancesService, stripeService, jobQueueService, walletSettingWithWallet, walletSetting, job, jobMeta } = - setup(); - const paymentMethod = generatePaymentMethod(); - const balance = generateBalance({ balance: 30.0, deployments: 0, total: 30.0 }); - walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(walletSettingWithWallet); - balancesService.getFullBalanceInFiat.mockResolvedValue(balance); - stripeService.getDefaultPaymentMethod.mockResolvedValue(paymentMethod); - jobQueueService.enqueue.mockResolvedValue(faker.string.uuid()); + it("triggers reload with minimum amount when needed amount is below minimum", async () => { + // Given: balance = $4, costUntilTargetDate = $20 + // Expected: 25% threshold = $5, balance ($4) < threshold → reload + // Expected: reload amount = max($20 - $4, $20) = $20 (minimum) + const balance = 4.0; + const costUntilTargetDateInDenom = 20_000_000; // 20 USD in udenom + const costUntilTargetDateInFiat = 20.0; + const expectedReloadAmount = 20.0; // max(20 - 4, 20) = 20 + + const { handler, stripeService, job, jobMeta } = setup({ + balance: { total: balance }, + weeklyCostInDenom: costUntilTargetDateInDenom, + weeklyCostInFiat: costUntilTargetDateInFiat + }); await handler.handle(job, jobMeta); - expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); expect(stripeService.createPaymentIntent).toHaveBeenCalledWith({ - customer: walletSettingWithWallet.user.stripeCustomerId, - payment_method: paymentMethod.id, - amount: walletSetting.autoReloadAmount, + customer: expect.any(String), + payment_method: expect.any(String), + amount: expectedReloadAmount, currency: "usd", confirm: true, idempotencyKey: `${WalletBalanceReloadCheck.name}.${jobMeta.id}` }); - expect(jobQueueService.enqueue).toHaveBeenCalled(); }); - it("does not trigger reload when balance is above threshold", async () => { - const { handler, walletSettingRepository, balancesService, stripeService, jobQueueService, loggerService, walletSettingWithWallet, job, jobMeta } = - setup(); - const balance = generateBalance({ balance: 50.0, deployments: 0, total: 50.0 }); - walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(walletSettingWithWallet); - balancesService.getFullBalanceInFiat.mockResolvedValue(balance); - stripeService.getDefaultPaymentMethod.mockResolvedValue(generatePaymentMethod()); - jobQueueService.enqueue.mockResolvedValue(faker.string.uuid()); + it("does not trigger reload when balance equals 25% of cost", async () => { + // Given: balance = $12.50, costUntilTargetDate = $50 + // Expected: 25% threshold = $12.50, balance ($12.50) >= threshold → no reload + const balance = 12.5; + const costUntilTargetDateInDenom = 50_000_000; + const costUntilTargetDateInFiat = 50.0; + + const { handler, stripeService, loggerService, job, jobMeta } = setup({ + balance: { total: balance }, + weeklyCostInDenom: costUntilTargetDateInDenom, + weeklyCostInFiat: costUntilTargetDateInFiat + }); await handler.handle(job, jobMeta); - expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); expect(stripeService.createPaymentIntent).not.toHaveBeenCalled(); expect(loggerService.info).toHaveBeenCalledWith( expect.objectContaining({ - event: "WALLET_BALANCE_RELOAD_SKIPPED" + event: "WALLET_BALANCE_RELOAD_SKIPPED", + balance, + costUntilTargetDateInFiat }) ); - expect(jobQueueService.enqueue).toHaveBeenCalled(); }); - it("re-enqueues next check and updates job ID", async () => { - const { handler, walletSettingRepository, balancesService, stripeService, jobQueueService, walletSettingWithWallet, walletSetting, job, jobMeta } = - setup(); - const jobId = faker.string.uuid(); - const balance = generateBalance({ balance: 50.0, deployments: 0, total: 50.0 }); - walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(walletSettingWithWallet); - balancesService.getFullBalanceInFiat.mockResolvedValue(balance); - stripeService.getDefaultPaymentMethod.mockResolvedValue(generatePaymentMethod()); - jobQueueService.enqueue.mockResolvedValue(jobId); + it("does not trigger reload when balance is above 25% of cost", async () => { + // Given: balance = $50, costUntilTargetDate = $50 + // Expected: 25% threshold = $12.50, balance ($50) >= threshold → no reload + const balance = 50.0; + const costUntilTargetDateInDenom = 50_000_000; + const costUntilTargetDateInFiat = 50.0; + + const { handler, stripeService, loggerService, job, jobMeta } = setup({ + balance: { total: balance }, + weeklyCostInDenom: costUntilTargetDateInDenom, + weeklyCostInFiat: costUntilTargetDateInFiat + }); await handler.handle(job, jobMeta); - expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); - expect(jobQueueService.enqueue).toHaveBeenCalledWith( - expect.any(WalletBalanceReloadCheck), + expect(stripeService.createPaymentIntent).not.toHaveBeenCalled(); + expect(loggerService.info).toHaveBeenCalledWith( expect.objectContaining({ - singletonKey: `WalletBalanceReloadCheck.${job.userId}` + event: "WALLET_BALANCE_RELOAD_SKIPPED", + balance, + costUntilTargetDateInFiat }) ); - expect(walletSettingRepository.updateById).toHaveBeenCalledWith(walletSetting.id, { autoReloadJobId: jobId }); }); - it("does not update job ID when enqueue returns null", async () => { - const { handler, walletSettingRepository, balancesService, stripeService, jobQueueService, walletSettingWithWallet, job, jobMeta } = setup(); - const balance = generateBalance({ balance: 50.0, deployments: 0, total: 50.0 }); - walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(walletSettingWithWallet); - balancesService.getFullBalanceInFiat.mockResolvedValue(balance); - stripeService.getDefaultPaymentMethod.mockResolvedValue(generatePaymentMethod()); - jobQueueService.enqueue.mockResolvedValue(null); + it("schedules next check and updates job ID", async () => { + const jobId = faker.string.uuid(); + const balance = 50.0; + const weeklyCostInDenom = 50_000_000; + const weeklyCostInFiat = 50.0; + + const { handler, walletReloadJobService, walletSetting, job, jobMeta } = setup({ + balance: { total: balance }, + weeklyCostInDenom, + weeklyCostInFiat, + jobId + }); await handler.handle(job, jobMeta); - expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); - expect(walletSettingRepository.updateById).not.toHaveBeenCalled(); + expect(walletReloadJobService.scheduleForWalletSetting).toHaveBeenCalledWith( + expect.objectContaining({ + id: walletSetting.id, + userId: job.userId + }), + expect.objectContaining({ + startAfter: expect.any(Date), + prevAction: "complete" + }) + ); + }); + + it("logs error and throws when scheduling next check fails", async () => { + const balance = 50.0; + const weeklyCostInDenom = 50_000_000; + const weeklyCostInFiat = 50.0; + const error = new Error("Failed to schedule"); + + const { handler, walletReloadJobService, loggerService, job, jobMeta } = setup({ + balance: { total: balance }, + weeklyCostInDenom, + weeklyCostInFiat + }); + walletReloadJobService.scheduleForWalletSetting.mockRejectedValue(error); + + await expect(handler.handle(job, jobMeta)).rejects.toThrow(error); + + expect(loggerService.error).toHaveBeenCalledWith( + expect.objectContaining({ + event: "ERROR_SCHEDULING_NEXT_CHECK", + walletAddress: expect.any(String), + error + }) + ); }); it("logs validation error when wallet setting not found", async () => { - const { handler, walletSettingRepository, loggerService, job, jobMeta } = setup(); - walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(undefined); + const { handler, walletSettingRepository, loggerService, job, jobMeta } = setup({ + walletSettingNotFound: true + }); await handler.handle(job, jobMeta); - expect(loggerService.info).toHaveBeenCalledWith({ + expect(loggerService.error).toHaveBeenCalledWith({ event: "WALLET_SETTING_NOT_FOUND", message: "Wallet setting not found. Skipping wallet balance reload check.", userId: job.userId @@ -157,60 +215,29 @@ describe(WalletBalanceReloadCheckHandler.name, () => { }); it("logs validation error when auto reload is disabled", async () => { - const { handler, walletSettingRepository, loggerService, walletSettingWithWallet, job, jobMeta } = setup(); - const disabledSetting = { ...walletSettingWithWallet, autoReloadEnabled: false }; - walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(disabledSetting); + const { handler, walletSettingRepository, loggerService, job, jobMeta } = setup({ + autoReloadEnabled: false + }); await handler.handle(job, jobMeta); expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); - expect(loggerService.info).toHaveBeenCalledWith({ + expect(loggerService.error).toHaveBeenCalledWith({ event: "AUTO_RELOAD_DISABLED", message: "Auto reload disabled. Skipping wallet balance reload check.", userId: job.userId }); }); - it("logs validation error when auto reload threshold is not set", async () => { - const { handler, walletSettingRepository, loggerService, walletSettingWithWallet, job, jobMeta } = setup(); - const invalidSetting = { ...walletSettingWithWallet, autoReloadThreshold: undefined }; - walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(invalidSetting); - - await handler.handle(job, jobMeta); - - expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); - expect(loggerService.info).toHaveBeenCalledWith({ - event: "AUTO_RELOAD_THRESHOLD_NOT_SET", - message: "Auto reload threshold not set. Skipping wallet balance reload check.", - userId: job.userId - }); - }); - - it("logs validation error when auto reload amount is not set", async () => { - const { handler, walletSettingRepository, loggerService, walletSettingWithWallet, job, jobMeta } = setup(); - const invalidSetting = { ...walletSettingWithWallet, autoReloadAmount: undefined }; - walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(invalidSetting); - - await handler.handle(job, jobMeta); - - expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); - expect(loggerService.info).toHaveBeenCalledWith({ - event: "AUTO_RELOAD_AMOUNT_NOT_SET", - message: "Auto reload amount not set. Skipping wallet balance reload check.", - userId: job.userId - }); - }); - it("logs validation error when wallet is not initialized", async () => { - const { handler, walletSettingRepository, loggerService, walletSettingWithWallet, job, jobMeta } = setup(); - const walletWithoutAddress = UserWalletSeeder.create({ address: null }); - const settingWithUninitializedWallet = { ...walletSettingWithWallet, wallet: walletWithoutAddress }; - walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(settingWithUninitializedWallet); + const { handler, walletSettingRepository, loggerService, job, jobMeta } = setup({ + wallet: UserWalletSeeder.create({ address: null }) + }); await handler.handle(job, jobMeta); expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); - expect(loggerService.info).toHaveBeenCalledWith({ + expect(loggerService.error).toHaveBeenCalledWith({ event: "WALLET_NOT_INITIALIZED", message: "Wallet not initialized. Skipping wallet balance reload check.", userId: job.userId @@ -218,15 +245,16 @@ describe(WalletBalanceReloadCheckHandler.name, () => { }); it("logs validation error when user stripe customer ID is not set", async () => { - const { handler, walletSettingRepository, loggerService, walletSettingWithWallet, job, jobMeta } = setup(); - const userWithoutStripe = { ...walletSettingWithWallet.user, stripeCustomerId: null }; - const settingWithoutStripe = { ...walletSettingWithWallet, user: userWithoutStripe }; - walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(settingWithoutStripe); + const userWithoutStripe = UserSeeder.create(); + const userWithNullStripe = { ...userWithoutStripe, stripeCustomerId: null }; + const { handler, walletSettingRepository, loggerService, job, jobMeta } = setup({ + user: userWithNullStripe + }); await handler.handle(job, jobMeta); expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); - expect(loggerService.info).toHaveBeenCalledWith({ + expect(loggerService.error).toHaveBeenCalledWith({ event: "USER_STRIPE_CUSTOMER_ID_NOT_SET", message: "User stripe customer ID not set. Skipping wallet balance reload check.", userId: job.userId @@ -234,35 +262,47 @@ describe(WalletBalanceReloadCheckHandler.name, () => { }); it("logs validation error when default payment method cannot be retrieved", async () => { - const { handler, walletSettingRepository, balancesService, stripeService, loggerService, walletSettingWithWallet, job, jobMeta } = setup(); - const balance = generateBalance({ balance: 15.0, deployments: 0, total: 15.0 }); - walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(walletSettingWithWallet); - balancesService.getFullBalanceInFiat.mockResolvedValue(balance); - const error = createHttpError(404, "Default payment method not found", { source: "stripe" }); - stripeService.getDefaultPaymentMethod.mockRejectedValue(error); + const balance = 15.0; + + const { handler, loggerService, stripeService, job, jobMeta } = setup({ + balance: { total: balance } + }); + stripeService.getDefaultPaymentMethod.mockResolvedValue(undefined); await handler.handle(job, jobMeta); - expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); - expect(loggerService.info).toHaveBeenCalledWith({ - event: "ERROR_RETRIEVING_DEFAULT_PAYMENT_METHOD", + expect(loggerService.error).toHaveBeenCalledWith({ + event: "DEFAULT_PAYMENT_METHOD_NOT_FOUND", message: "Default payment method not found", - source: "stripe", userId: job.userId }); }); }); - function setup() { - const user = UserSeeder.create(); - const userWithStripe = { ...user, stripeCustomerId: faker.string.uuid() }; - const wallet = UserWalletSeeder.create({ userId: user.id }); + function setup(input?: { + balance?: { total: number }; + weeklyCostInDenom?: number; + weeklyCostInFiat?: number; + jobId?: string | null; + walletSettingNotFound?: boolean; + autoReloadEnabled?: boolean; + wallet?: ReturnType; + user?: ReturnType; + }) { + const user = input?.user ?? UserSeeder.create(); + const userWithStripe = + input?.user && input.user.stripeCustomerId === null + ? user + : input?.user && input.user.stripeCustomerId + ? user + : user.stripeCustomerId + ? user + : { ...user, stripeCustomerId: faker.string.uuid() }; + const wallet = input?.wallet ?? UserWalletSeeder.create({ userId: user.id }); const walletSetting = generateWalletSetting({ userId: user.id, walletId: wallet.id, - autoReloadEnabled: true, - autoReloadThreshold: 30.0, - autoReloadAmount: 100.0 + autoReloadEnabled: input?.autoReloadEnabled ?? true }); const walletSettingWithWallet = { ...walletSetting, @@ -282,19 +322,47 @@ describe(WalletBalanceReloadCheckHandler.name, () => { const walletSettingRepository = mock(); const balancesService = mock(); + const walletReloadJobService = mock(); + const drainingDeploymentService = mock(); const stripeService = mock(); - stripeService.getDefaultPaymentMethod.mockResolvedValue(generatePaymentMethod()); - const jobQueueService = mock(); const loggerService = mock(); - const handler = new WalletBalanceReloadCheckHandler(walletSettingRepository, balancesService, jobQueueService, stripeService, loggerService); + const balance = input?.balance ?? { total: 50.0 }; + const weeklyCostInDenom = input?.weeklyCostInDenom ?? 50_000_000; + const weeklyCostInFiat = input?.weeklyCostInFiat ?? 50.0; + const jobId = input?.jobId ?? faker.string.uuid(); + + if (input?.walletSettingNotFound) { + walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(undefined); + } else { + walletSettingRepository.findInternalByUserIdWithRelations.mockResolvedValue(walletSettingWithWallet); + } + + if (!input?.walletSettingNotFound && userWithStripe.stripeCustomerId) { + balancesService.getFullBalanceInFiat.mockResolvedValue(generateBalance(balance)); + balancesService.toFiatAmount.mockResolvedValue(weeklyCostInFiat); + drainingDeploymentService.calculateAllDeploymentCostUntilDate.mockResolvedValue(weeklyCostInDenom); + stripeService.getDefaultPaymentMethod.mockResolvedValue(generatePaymentMethod()); + } + + walletReloadJobService.scheduleForWalletSetting.mockResolvedValue(jobId); + + const handler = new WalletBalanceReloadCheckHandler( + walletSettingRepository, + balancesService, + walletReloadJobService, + stripeService, + drainingDeploymentService, + loggerService + ); return { handler, walletSettingRepository, balancesService, + walletReloadJobService, + drainingDeploymentService, stripeService, - jobQueueService, loggerService, walletSetting, walletSettingWithWallet, diff --git a/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts index 42de74893e..524d241aa9 100644 --- a/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts +++ b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts @@ -1,6 +1,5 @@ import { createMongoAbility } from "@casl/ability"; -import addDays from "date-fns/addDays"; -import { isHttpError } from "http-errors"; +import { addMilliseconds, millisecondsInHour } from "date-fns"; import { Err, Ok, Result } from "ts-results"; import { singleton } from "tsyringe"; @@ -9,24 +8,19 @@ import type { GetBalancesResponseOutput } from "@src/billing/http-schemas/balanc import { UserWalletOutput, WalletSettingOutput, WalletSettingRepository } from "@src/billing/repositories"; import { BalancesService } from "@src/billing/services/balances/balances.service"; import { PaymentMethod, StripeService } from "@src/billing/services/stripe/stripe.service"; -import { JobHandler, JobMeta, JobPayload, JobQueueService, LoggerService } from "@src/core"; +import { WalletReloadJobService } from "@src/billing/services/wallet-reload-job/wallet-reload-job.service"; +import { JobHandler, JobMeta, JobPayload, LoggerService } from "@src/core"; +import type { Require } from "@src/core/types/require.type"; +import { DrainingDeploymentService } from "@src/deployment/services/draining-deployment/draining-deployment.service"; import { isPayingUser, PayingUser } from "../paying-user/paying-user"; type ValidationError = { event: string; message: string; - error?: unknown; -}; - -type Require = Omit & { - [P in K]-?: NonNullable; }; type InitializedWallet = Require, "address">; -type ActionableWalletSetting = Require< - Pick, - "autoReloadThreshold" | "autoReloadAmount" ->; +type ActionableWalletSetting = Pick; type Resources = { walletSetting: ActionableWalletSetting; @@ -35,6 +29,8 @@ type Resources = { }; type AllResources = Resources & { balance: GetBalancesResponseOutput["data"]["total"]; paymentMethod: PaymentMethod }; +const millisecondsInDay = 24 * millisecondsInHour; + @singleton() export class WalletBalanceReloadCheckHandler implements JobHandler { public readonly accepts = WalletBalanceReloadCheck; @@ -43,11 +39,20 @@ export class WalletBalanceReloadCheckHandler implements JobHandler> { - try { - return Ok( - await this.stripeService.getDefaultPaymentMethod( - user, - createMongoAbility([ - { - action: "read", - subject: "PaymentMethod" - } - ]) - ) - ); - } catch (error) { - if (isHttpError(error)) { - return Err({ - event: "ERROR_RETRIEVING_DEFAULT_PAYMENT_METHOD", - message: error.message, - error - }); - } - - throw error; + const paymentMethod = await this.stripeService.getDefaultPaymentMethod( + user, + createMongoAbility([ + { + action: "read", + subject: "PaymentMethod" + } + ]) + ); + + if (paymentMethod) { + return Ok(paymentMethod); } + + return Err({ + event: "DEFAULT_PAYMENT_METHOD_NOT_FOUND", + message: "Default payment method not found" + }); } #finishWithValidationError(error: ValidationError, userId: JobPayload["userId"]): void { - this.loggerService.info({ + this.loggerService.error({ ...error, userId: userId }); } async #tryToReload(resources: AllResources & { job: JobMeta }): Promise { + const reloadTargetDate = addMilliseconds(new Date(), this.#RELOAD_COVERAGE_PERIOD_IN_MS); + const costUntilTargetDateInDenom = await this.drainingDeploymentService.calculateAllDeploymentCostUntilDate(resources.wallet.address, reloadTargetDate); + const costUntilTargetDateInFiat = await this.balancesService.toFiatAmount(costUntilTargetDateInDenom); + const threshold = this.#MIN_COVERAGE_PERCENTAGE * costUntilTargetDateInFiat; const log = { walletAddress: resources.wallet.address, balance: resources.balance, - threshold: resources.walletSetting.autoReloadThreshold, - amount: resources.walletSetting.autoReloadAmount + costUntilTargetDateInFiat, + threshold }; + if (costUntilTargetDateInFiat === 0 || resources.balance >= threshold) { + this.loggerService.info({ + ...log, + event: "WALLET_BALANCE_RELOAD_SKIPPED" + }); + return; + } + try { - if (resources.walletSetting.autoReloadThreshold >= resources.balance) { - await this.stripeService.createPaymentIntent({ - customer: resources.user.stripeCustomerId, - payment_method: resources.paymentMethod.id, - amount: resources.walletSetting.autoReloadAmount, - currency: "usd", - confirm: true, - idempotencyKey: `${WalletBalanceReloadCheck.name}.${resources.job.id}` - }); - this.loggerService.info({ - ...log, - event: "WALLET_BALANCE_RELOADED" - }); - } else { - this.loggerService.info({ - ...log, - event: "WALLET_BALANCE_RELOAD_SKIPPED" - }); - } + const reloadAmountInFiat = Math.max(costUntilTargetDateInFiat - resources.balance, this.#MIN_RELOAD_AMOUNT_IN_USD); + + await this.stripeService.createPaymentIntent({ + customer: resources.user.stripeCustomerId, + payment_method: resources.paymentMethod.id, + amount: reloadAmountInFiat, + currency: "usd", + confirm: true, + idempotencyKey: `${WalletBalanceReloadCheck.name}.${resources.job.id}` + }); + this.loggerService.info({ + ...log, + amount: reloadAmountInFiat, + event: "WALLET_BALANCE_RELOADED" + }); } catch (error) { this.loggerService.error({ + ...log, event: "WALLET_BALANCE_RELOAD_FAILED", error: error }); @@ -213,34 +209,22 @@ export class WalletBalanceReloadCheckHandler implements JobHandler { - const jobId = await this.jobQueueService.enqueue(new WalletBalanceReloadCheck({ userId: resources.user.id }), { - singletonKey: `${WalletBalanceReloadCheck.name}.${resources.user.id}`, - startAfter: await this.#calculateNextCheckDate(resources.wallet) - }); - - if (jobId) { - try { - await this.walletSettingRepository.updateById(resources.walletSetting.id, { autoReloadJobId: jobId }); - } catch (error) { - this.loggerService.error({ - event: "ERROR_UPDATING_AUTO_RELOAD_JOB_ID", - error: error - }); - } - } else { - this.loggerService.info({ - event: "FAILED_OBTAINING_NEXT_JOB_ID", - walletAddress: resources.wallet.address + try { + await this.walletReloadJobService.scheduleForWalletSetting(resources.walletSetting, { + startAfter: this.#calculateNextCheckDate(), + prevAction: "complete" + }); + } catch (error) { + this.loggerService.error({ + event: "ERROR_SCHEDULING_NEXT_CHECK", + walletAddress: resources.wallet.address, + error: error }); + throw error; } } - async #calculateNextCheckDate(wallet: InitializedWallet): Promise { - this.loggerService.info({ - event: "CALCULATING_NEXT_CHECK_DATE", - walletAddress: wallet.address, - note: "Implementation pending..." - }); - return addDays(new Date(), 1); + #calculateNextCheckDate(): Date { + return addMilliseconds(new Date(), this.#CHECK_INTERVAL_IN_MS); } } diff --git a/apps/api/src/billing/services/wallet-reload-job/wallet-reload-job.service.ts b/apps/api/src/billing/services/wallet-reload-job/wallet-reload-job.service.ts new file mode 100644 index 0000000000..cdf725debe --- /dev/null +++ b/apps/api/src/billing/services/wallet-reload-job/wallet-reload-job.service.ts @@ -0,0 +1,59 @@ +import { singleton } from "tsyringe"; +import { v4 as uuidv4 } from "uuid"; + +import { WalletBalanceReloadCheck } from "@src/billing/events/wallet-balance-reload-check"; +import { WalletSettingOutput, WalletSettingRepository } from "@src/billing/repositories"; +import { EnqueueOptions, JobQueueService, TxService } from "@src/core"; + +@singleton() +export class WalletReloadJobService { + constructor( + private readonly walletSettingRepository: WalletSettingRepository, + private readonly jobQueueService: JobQueueService, + private readonly txService: TxService + ) {} + + async scheduleImmediate(userId: string): Promise { + const walletSetting = await this.walletSettingRepository.findByUserId(userId); + + if (!walletSetting || !walletSetting.userId) { + return; + } + + await this.scheduleForWalletSetting(walletSetting); + } + + async scheduleForWalletSetting( + walletSetting: Pick, + options?: Pick & { prevAction?: "cancel" | "complete" } + ): Promise { + return await this.txService.transaction(async () => { + if (walletSetting.autoReloadJobId) { + if (options?.prevAction === "cancel") { + await this.jobQueueService.cancel(WalletBalanceReloadCheck.name, walletSetting.autoReloadJobId); + } else { + await this.jobQueueService.complete(WalletBalanceReloadCheck.name, walletSetting.autoReloadJobId); + } + } + + const jobId = uuidv4(); + await this.walletSettingRepository.updateById(walletSetting.id, { autoReloadJobId: jobId }); + + const createdJobId = await this.jobQueueService.enqueue(new WalletBalanceReloadCheck({ userId: walletSetting.userId }), { + singletonKey: `${WalletBalanceReloadCheck.name}.${walletSetting.userId}`, + id: jobId, + ...(options?.startAfter && { startAfter: options.startAfter }) + }); + + if (!createdJobId) { + throw new Error("Failed to schedule wallet balance reload check"); + } + + return jobId; + }); + } + + async cancel(userId: string, jobId: string): Promise { + await this.jobQueueService.cancel(WalletBalanceReloadCheck.name, jobId); + } +} diff --git a/apps/api/src/billing/services/wallet-settings/wallet-settings.service.spec.ts b/apps/api/src/billing/services/wallet-settings/wallet-settings.service.spec.ts index f5723e122a..10d27add77 100644 --- a/apps/api/src/billing/services/wallet-settings/wallet-settings.service.spec.ts +++ b/apps/api/src/billing/services/wallet-settings/wallet-settings.service.spec.ts @@ -2,13 +2,11 @@ import { createMongoAbility } from "@casl/ability"; import { faker } from "@faker-js/faker"; import { mock } from "jest-mock-extended"; import { PostgresError } from "postgres"; -import { v4 as uuidv4 } from "uuid"; import type { AuthService } from "@src/auth/services/auth.service"; -import { WalletBalanceReloadCheck } from "@src/billing/events/wallet-balance-reload-check"; import type { UserWalletRepository, WalletSettingRepository } from "@src/billing/repositories"; import type { PaymentMethod, StripeService } from "@src/billing/services/stripe/stripe.service"; -import type { JobQueueService, TxService } from "@src/core"; +import type { WalletReloadJobService } from "@src/billing/services/wallet-reload-job/wallet-reload-job.service"; import type { UserRepository } from "@src/user/repositories"; import { WalletSettingService } from "./wallet-settings.service"; @@ -17,9 +15,6 @@ import { UserSeeder } from "@test/seeders/user.seeder"; import { UserWalletSeeder } from "@test/seeders/user-wallet.seeder"; import { generateWalletSetting } from "@test/seeders/wallet-setting.seeder"; -jest.mock("uuid"); -const uuidMock = uuidv4 as jest.MockedFn; - describe(WalletSettingService.name, () => { describe("getWalletSetting", () => { it("returns wallet setting when found", async () => { @@ -70,7 +65,7 @@ describe(WalletSettingService.name, () => { }); it("creates new wallet setting when not exists", async () => { - const { user, userWalletRepository, userWallet, walletSettingRepository, jobQueueService, jobId, service } = setup(); + const { user, userWalletRepository, userWallet, walletSettingRepository, walletReloadJobService, jobId, service } = setup(); const newSetting = generateWalletSetting({ userId: user.id, walletId: userWallet.id, @@ -80,7 +75,7 @@ describe(WalletSettingService.name, () => { }); walletSettingRepository.findByUserId.mockResolvedValue(undefined); walletSettingRepository.create.mockResolvedValue(newSetting); - jobQueueService.enqueue.mockResolvedValue(jobId); + walletReloadJobService.scheduleForWalletSetting.mockResolvedValue(jobId); const result = await service.upsertWalletSetting(user.id, { autoReloadEnabled: true, @@ -100,11 +95,12 @@ describe(WalletSettingService.name, () => { autoReloadThreshold: 10.5, autoReloadAmount: 50.0 }); - expect(jobQueueService.enqueue).toHaveBeenCalledWith(expect.any(WalletBalanceReloadCheck), { - singletonKey: `WalletBalanceReloadCheck.${user.id}`, - id: jobId - }); - expect(walletSettingRepository.updateById).toHaveBeenCalledWith(newSetting.id, { autoReloadJobId: jobId }); + expect(walletReloadJobService.scheduleForWalletSetting).toHaveBeenCalledWith( + expect.objectContaining({ + id: newSetting.id, + userId: user.id + }) + ); }); it("retries the update in case of a race condition", async () => { @@ -173,7 +169,7 @@ describe(WalletSettingService.name, () => { }); it("updates existing setting using existing values when enabled is true and threshold and amount are not provided", async () => { - const { user, walletSetting, walletSettingRepository, jobQueueService, jobId, service } = setup(); + const { user, walletSetting, walletSettingRepository, walletReloadJobService, jobId, service } = setup(); const existingSetting = { ...walletSetting, autoReloadEnabled: false, autoReloadThreshold: 15.5, autoReloadAmount: 25.0 }; const updatedSetting = generateWalletSetting({ userId: user.id, @@ -183,7 +179,7 @@ describe(WalletSettingService.name, () => { }); walletSettingRepository.findByUserId.mockResolvedValue(existingSetting); walletSettingRepository.updateById.mockResolvedValue(updatedSetting as any); - jobQueueService.enqueue.mockResolvedValue(jobId); + walletReloadJobService.scheduleForWalletSetting.mockResolvedValue(jobId); const result = await service.upsertWalletSetting(user.id, { autoReloadEnabled: true @@ -198,15 +194,16 @@ describe(WalletSettingService.name, () => { }, { returning: true } ); - expect(jobQueueService.enqueue).toHaveBeenCalledWith(expect.any(WalletBalanceReloadCheck), { - singletonKey: `WalletBalanceReloadCheck.${user.id}`, - id: jobId - }); - expect(walletSettingRepository.updateById).toHaveBeenCalledWith(updatedSetting.id, { autoReloadJobId: jobId }); + expect(walletReloadJobService.scheduleForWalletSetting).toHaveBeenCalledWith( + expect.objectContaining({ + id: updatedSetting.id, + userId: user.id + }) + ); }); it("cancels job when auto-reload is disabled", async () => { - const { user, walletSetting, walletSettingRepository, jobQueueService, service } = setup(); + const { user, walletSetting, walletSettingRepository, walletReloadJobService, service } = setup(); const existingJobId = faker.string.uuid(); const existingSetting = { ...walletSetting, autoReloadEnabled: true, autoReloadJobId: existingJobId }; const updatedSetting = { @@ -231,7 +228,7 @@ describe(WalletSettingService.name, () => { }, { returning: true } ); - expect(jobQueueService.cancel).toHaveBeenCalledWith(WalletBalanceReloadCheck.name, existingJobId); + expect(walletReloadJobService.cancel).toHaveBeenCalledWith(user.id, existingJobId); }); it("throws 400 when enabled is true and existing setting does not have threshold and amount", async () => { @@ -298,22 +295,11 @@ describe(WalletSettingService.name, () => { ability }); const jobId = faker.string.uuid(); - uuidMock.mockReturnValue(jobId); - const jobQueueService = mock({ + const walletReloadJobService = mock({ + scheduleForWalletSetting: jest.fn().mockResolvedValue(jobId), cancel: jest.fn().mockResolvedValue(undefined) }); - const txService = mock({ - transaction: jest.fn(async (cb: () => Promise) => await cb()) as TxService["transaction"] - }); - const service = new WalletSettingService( - walletSettingRepository, - userWalletRepository, - userRepository, - stripeService, - authService, - jobQueueService, - txService - ); + const service = new WalletSettingService(walletSettingRepository, userWalletRepository, userRepository, stripeService, authService, walletReloadJobService); const { autoReloadJobId, ...publicSetting } = walletSetting; return { @@ -326,7 +312,7 @@ describe(WalletSettingService.name, () => { userRepository, stripeService, authService, - jobQueueService, + walletReloadJobService, jobId, service }; diff --git a/apps/api/src/billing/services/wallet-settings/wallet-settings.service.ts b/apps/api/src/billing/services/wallet-settings/wallet-settings.service.ts index 64a939672d..1828158136 100644 --- a/apps/api/src/billing/services/wallet-settings/wallet-settings.service.ts +++ b/apps/api/src/billing/services/wallet-settings/wallet-settings.service.ts @@ -1,12 +1,11 @@ import assert from "http-assert"; import { singleton } from "tsyringe"; -import { v4 as uuidv4 } from "uuid"; import { AuthService } from "@src/auth/services/auth.service"; -import { WalletBalanceReloadCheck } from "@src/billing/events/wallet-balance-reload-check"; import { UserWalletRepository, type WalletSettingOutput, WalletSettingRepository } from "@src/billing/repositories"; import { StripeService } from "@src/billing/services/stripe/stripe.service"; -import { JobQueueService, TxService, WithTransaction } from "@src/core"; +import { WalletReloadJobService } from "@src/billing/services/wallet-reload-job/wallet-reload-job.service"; +import { WithTransaction } from "@src/core"; import { isUniqueViolation } from "@src/core/repositories/base.repository"; import { UserOutput, UserRepository } from "@src/user/repositories"; @@ -24,8 +23,7 @@ export class WalletSettingService { private readonly userRepository: UserRepository, private readonly stripeService: StripeService, private readonly authService: AuthService, - private readonly jobQueueService: JobQueueService, - private readonly txService: TxService + private readonly walletReloadJobService: WalletReloadJobService ) {} async getWalletSetting(userId: string): Promise | undefined> { @@ -134,34 +132,14 @@ export class WalletSettingService { async #arrangeSchedule(prev?: WalletSettingOutput, next?: WalletSettingOutput) { if (!prev?.autoReloadEnabled && next?.autoReloadEnabled) { - await this.#schedule(next); + await this.walletReloadJobService.scheduleForWalletSetting(next); } if (!next?.autoReloadEnabled && next?.autoReloadJobId) { - await this.jobQueueService.cancel(WalletBalanceReloadCheck.name, next.autoReloadJobId); + await this.walletReloadJobService.cancel(next.userId, next.autoReloadJobId); } } - async #schedule(walletSetting: WalletSettingOutput) { - return await this.txService.transaction(async () => { - if (walletSetting.autoReloadJobId) { - await this.jobQueueService.cancel(WalletBalanceReloadCheck.name, walletSetting.autoReloadJobId); - } - - const jobId = uuidv4(); - await this.walletSettingRepository.updateById(walletSetting.id, { autoReloadJobId: jobId }); - - const createdJobId = await this.jobQueueService.enqueue(new WalletBalanceReloadCheck({ userId: walletSetting.userId }), { - singletonKey: `${WalletBalanceReloadCheck.name}.${walletSetting.userId}`, - id: jobId - }); - - assert(createdJobId, 500, "Failed to schedule wallet balance reload check"); - - return jobId; - }); - } - async deleteWalletSetting(userId: string): Promise { const { ability } = this.authService; const walletSetting = await this.walletSettingRepository.accessibleBy(ability, "read").findByUserId(userId); @@ -170,7 +148,7 @@ export class WalletSettingService { await Promise.all([ this.walletSettingRepository.accessibleBy(ability, "delete").deleteBy({ userId }), - ...(walletSetting.autoReloadJobId ? [this.jobQueueService.cancel(WalletBalanceReloadCheck.name, walletSetting.autoReloadJobId)] : []) + ...(walletSetting.autoReloadJobId ? [this.walletReloadJobService.cancel(userId, walletSetting.autoReloadJobId)] : []) ]); } } diff --git a/apps/api/src/core/services/job-queue/job-queue.service.spec.ts b/apps/api/src/core/services/job-queue/job-queue.service.spec.ts index 8d10a1a195..5601235f48 100644 --- a/apps/api/src/core/services/job-queue/job-queue.service.spec.ts +++ b/apps/api/src/core/services/job-queue/job-queue.service.spec.ts @@ -95,7 +95,8 @@ describe(JobQueueService.name, () => { expect(logger.info).toHaveBeenCalledWith({ event: "JOB_ENQUEUED", job, - jobId: "job-id-123" + jobId: "job-id-123", + options: { startAfter: expect.any(Date) } }); expect(result).toBe("job-id-123"); }); diff --git a/apps/api/src/core/services/job-queue/job-queue.service.ts b/apps/api/src/core/services/job-queue/job-queue.service.ts index bd6737e7f4..3170abbdd3 100644 --- a/apps/api/src/core/services/job-queue/job-queue.service.ts +++ b/apps/api/src/core/services/job-queue/job-queue.service.ts @@ -89,7 +89,8 @@ export class JobQueueService implements Disposable { this.logger.info({ event: "JOB_ENQUEUED", job, - jobId + jobId, + options }); return jobId; @@ -104,6 +105,15 @@ export class JobQueueService implements Disposable { }); } + async complete(name: string, id: string): Promise { + await this.pgBoss.complete(name, id); + this.logger.info({ + event: "JOB_COMPLETED", + id, + name + }); + } + /** Starts jobs processing */ async startWorkers({ concurrency, ...options }: ProcessOptions = {}): Promise { if (!this.handlers) throw new Error("Handlers not registered. Register handlers first."); @@ -211,7 +221,7 @@ export interface JobHandler { accepts: JobType; concurrency?: ProcessOptions["concurrency"]; policy?: PgBoss.Queue["policy"]; - handle(payload: JobPayload, job: JobMeta): Promise; + handle(payload: JobPayload, job?: JobMeta): Promise; } export type EnqueueOptions = PgBoss.SendOptions; diff --git a/apps/api/src/dashboard/services/stats/stats.service.ts b/apps/api/src/dashboard/services/stats/stats.service.ts index c8960a01b5..99838aef89 100644 --- a/apps/api/src/dashboard/services/stats/stats.service.ts +++ b/apps/api/src/dashboard/services/stats/stats.service.ts @@ -394,6 +394,11 @@ export class StatsService { }; } + async convertToFiatAmount(amount: number, denom: MarketDataParams["coin"]): Promise { + const marketData = await this.getMarketData(denom); + return amount * marketData.price; + } + async getLeasesDuration(owner: LeasesDurationParams["owner"], query: LeasesDurationQuery): Promise { const { dseq, startDate, endDate } = query; const closedLeases = await Lease.findAll({ 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 d6b6b60293..8b4dde514b 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 @@ -38,7 +38,7 @@ export class DeploymentSettingRepository extends BaseRepository { + async *paginateAutoTopUpDeployments(options: { address?: string; limit: number }): AsyncGenerator { let lastId: string | undefined; do { @@ -48,6 +48,10 @@ export class DeploymentSettingRepository extends BaseRepository { - let blockHttpService: jest.Mocked; - let leaseRepository: jest.Mocked; - let userWalletRepository: jest.Mocked; - let deploymentSettingRepository: jest.Mocked; - let service: DrainingDeploymentService; - let config: jest.Mocked; - const CURRENT_BLOCK_HEIGHT = 7481457; - - beforeEach(() => { - blockHttpService = { - getCurrentHeight: jest.fn().mockResolvedValue(CURRENT_BLOCK_HEIGHT), - getFutureBlockHeight: jest.fn() - } as Partial> as jest.Mocked; - - leaseRepository = { - findManyByDseqAndOwner: jest.fn(), - findOneByDseqAndOwner: jest.fn() - } as Partial> as jest.Mocked; - - userWalletRepository = { - findOneByUserId: jest.fn() - } as Partial> as jest.Mocked; - - deploymentSettingRepository = { - paginateAutoTopUpDeployments: jest.fn(), - updateManyById: jest.fn() - } as Partial> as jest.Mocked; - - const configValues = { - AUTO_TOP_UP_JOB_INTERVAL_IN_H: 1, - AUTO_TOP_UP_DEPLOYMENT_INTERVAL_IN_H: 3 - }; - - config = { - get: jest.fn().mockImplementation((key: keyof typeof configValues) => configValues[key]), - config: configValues - } as unknown as jest.Mocked; - - service = new DrainingDeploymentService(blockHttpService, leaseRepository, userWalletRepository, deploymentSettingRepository, config); - }); - describe("paginate", () => { const CURRENT_HEIGHT = 1000000; const LIMIT = 10; @@ -67,11 +29,10 @@ describe(DrainingDeploymentService.name, () => { const PREDICTED_CLOSURE_OFFSET_1 = 100; const PREDICTED_CLOSURE_OFFSET_2 = 200; - beforeEach(() => { - (blockHttpService.getCurrentHeight as jest.Mock).mockResolvedValue(CURRENT_HEIGHT); - }); - - it("should paginate draining deployments and mark closed ones as such", async () => { + it("paginates draining deployments and marks closed ones as such", async () => { + const { service, blockHttpService, leaseRepository, deploymentSettingRepository, config } = setup({ + currentHeight: CURRENT_HEIGHT + }); const deploymentSettings = AutoTopUpDeploymentSeeder.createMany(4); const addresses = deploymentSettings.map(s => s.address); const dseqs = deploymentSettings.map(s => Number(s.dseq)); @@ -98,13 +59,13 @@ describe(DrainingDeploymentService.name, () => { }) ]; - (deploymentSettingRepository.paginateAutoTopUpDeployments as jest.Mock).mockImplementation((_params: { limit: number }) => + deploymentSettingRepository.paginateAutoTopUpDeployments.mockImplementation((_params: { address?: string; limit: number }) => (async function* () { yield deploymentSettings; })() ); - (leaseRepository.findManyByDseqAndOwner as jest.Mock).mockResolvedValue(drainingDeployments); + leaseRepository.findManyByDseqAndOwner.mockResolvedValue(drainingDeployments); const callback = jest.fn(); for await (const result of service.paginate({ limit: LIMIT })) { @@ -146,10 +107,14 @@ describe(DrainingDeploymentService.name, () => { expect(callback.mock.calls[0][0]).toHaveLength(2); }); - it("should not call callback if no draining deployments found", async () => { - (deploymentSettingRepository.paginateAutoTopUpDeployments as jest.Mock).mockImplementation((_params: { limit: number }) => (async function* () {})()); + it("does not call callback when no draining deployments found", async () => { + const { service, deploymentSettingRepository, leaseRepository } = setup({ + currentHeight: CURRENT_HEIGHT + }); + + deploymentSettingRepository.paginateAutoTopUpDeployments.mockImplementation((_params: { address?: string; limit: number }) => (async function* () {})()); - (leaseRepository.findManyByDseqAndOwner as jest.Mock).mockResolvedValue([]); + leaseRepository.findManyByDseqAndOwner.mockResolvedValue([]); const callback = jest.fn(); for await (const result of service.paginate({ limit: LIMIT })) { @@ -162,42 +127,32 @@ describe(DrainingDeploymentService.name, () => { }); describe("calculateTopUpAmount", () => { - const TEST_CASES = [ - { - name: "should calculate amount for integer block rate", - input: { blockRate: 50 }, - expected: 90000 - }, - { - name: "should floor decimal block rate", - input: { blockRate: 10.7 }, - expected: 19260 - } - ]; + it("calculates amount for integer block rate", async () => { + const { service } = setup(); + const result = await service.calculateTopUpAmount({ blockRate: 50 }); + expect(result).toBe(90000); + }); - TEST_CASES.forEach(testCase => { - it(testCase.name, async () => { - const result = await service.calculateTopUpAmount(testCase.input); - expect(result).toBe(testCase.expected); - }); + it("floors decimal block rate", async () => { + const { service } = setup(); + const result = await service.calculateTopUpAmount({ blockRate: 10.7 }); + expect(result).toBe(19260); }); }); describe("calculateTopUpAmountForDseqAndUserId", () => { - const userId = faker.string.uuid(); - const dseq = faker.string.numeric(6); - const address = createAkashAddress(); - const userWallet = UserWalletSeeder.create({ address }); - const expectedTopUpAmount = 100000; + it("calculates top up amount for valid deployment", async () => { + const userId = faker.string.uuid(); + const dseq = faker.string.numeric(6); + const address = createAkashAddress(); + const userWallet = UserWalletSeeder.create({ address }); + const deployment = DrainingDeploymentSeeder.create(); + const expectedTopUpAmount = 100000; - beforeEach(() => { + const { service, userWalletRepository, leaseRepository } = setup(); userWalletRepository.findOneByUserId.mockResolvedValue(userWallet); - jest.spyOn(service, "calculateTopUpAmount").mockResolvedValue(expectedTopUpAmount); - }); - - it("should calculate top up amount for valid deployment", async () => { - const deployment = DrainingDeploymentSeeder.create(); leaseRepository.findOneByDseqAndOwner.mockResolvedValue(deployment); + jest.spyOn(service, "calculateTopUpAmount").mockResolvedValue(expectedTopUpAmount); const amount = await service.calculateTopUpAmountForDseqAndUserId(dseq, userId); @@ -207,26 +162,222 @@ describe(DrainingDeploymentService.name, () => { expect(amount).toBe(expectedTopUpAmount); }); - it("should return 0 if user wallet not found", async () => { + it("returns 0 when user wallet not found", async () => { + const userId = faker.string.uuid(); + const dseq = faker.string.numeric(6); + const { service, userWalletRepository, leaseRepository } = setup(); userWalletRepository.findOneByUserId.mockResolvedValue(undefined); const amount = await service.calculateTopUpAmountForDseqAndUserId(dseq, userId); expect(userWalletRepository.findOneByUserId).toHaveBeenCalledWith(userId); expect(leaseRepository.findOneByDseqAndOwner).not.toHaveBeenCalled(); - expect(service.calculateTopUpAmount).not.toHaveBeenCalled(); expect(amount).toBe(0); }); - it("should return 0 if lease not found", async () => { + it("returns 0 when lease not found", async () => { + const userId = faker.string.uuid(); + const dseq = faker.string.numeric(6); + const address = createAkashAddress(); + const userWallet = UserWalletSeeder.create({ address }); + const { service, userWalletRepository, leaseRepository } = setup(); + userWalletRepository.findOneByUserId.mockResolvedValue(userWallet); leaseRepository.findOneByDseqAndOwner.mockResolvedValue(null); const amount = await service.calculateTopUpAmountForDseqAndUserId(dseq, userId); expect(userWalletRepository.findOneByUserId).toHaveBeenCalledWith(userId); expect(leaseRepository.findOneByDseqAndOwner).toHaveBeenCalledWith(dseq, address); - expect(service.calculateTopUpAmount).not.toHaveBeenCalled(); expect(amount).toBe(0); }); }); + + describe("calculateAllDeploymentCostUntilDate", () => { + const CURRENT_HEIGHT = 1000000; + const BLOCK_RATE_1 = 50; + const BLOCK_RATE_2 = 75; + + it("calculates total cost for deployments closing within target date", async () => { + // Given: 2 deployments that will close before target date + // - Deployment 1: closes at height 1000100 (100 blocks from now), blockRate 50 + // - Deployment 2: closes at height 1000200 (200 blocks from now), blockRate 75 + // Target date is 1 week from now + // The method calculates blocksNeeded = targetHeight - currentHeight for all deployments + // Expected: blocksNeeded = targetHeight - currentHeight + // amount1 = 50 * blocksNeeded, amount2 = 75 * blocksNeeded + // total = (50 + 75) * blocksNeeded = 125 * blocksNeeded + const deployments = [ + { predictedClosedHeight: CURRENT_HEIGHT + 100, blockRate: BLOCK_RATE_1 }, + { predictedClosedHeight: CURRENT_HEIGHT + 200, blockRate: BLOCK_RATE_2 } + ]; + + const { service, address, targetDate } = await setupCalculateCost({ + currentHeight: CURRENT_HEIGHT, + deployments + }); + + const result = await service.calculateAllDeploymentCostUntilDate(address, targetDate); + + // Calculate expected: blocksNeeded = targetHeight - currentHeight + // For 1 week: targetHeight = currentHeight + averageBlockCountInAnHour * (7 * 24) + // blocksNeeded = averageBlockCountInAnHour * 168 + const hoursInWeek = 7 * 24; + const expectedBlocksNeeded = Math.floor(averageBlockCountInAnHour * hoursInWeek); + const expectedTotal = (BLOCK_RATE_1 + BLOCK_RATE_2) * expectedBlocksNeeded; + + // Allow for small differences in date calculations (±2 blocks = ±250 with total rate of 125) + expect(result).toBeGreaterThanOrEqual(expectedTotal - 250); + expect(result).toBeLessThanOrEqual(expectedTotal + 250); + }); + + it("returns 0 when user wallet not found", async () => { + const deployments = [{ predictedClosedHeight: CURRENT_HEIGHT + 100, blockRate: BLOCK_RATE_1 }]; + + const { service, address, targetDate } = await setupCalculateCost({ + userWallet: undefined, + deployments + }); + + const result = await service.calculateAllDeploymentCostUntilDate(address, targetDate); + + expect(result).toBe(0); + }); + + it("returns 0 when user wallet has no address", async () => { + const deployments = [{ predictedClosedHeight: CURRENT_HEIGHT + 100, blockRate: BLOCK_RATE_1 }]; + + const { service, address, targetDate } = await setupCalculateCost({ + userWallet: UserWalletSeeder.create({ address: null }), + deployments + }); + + const result = await service.calculateAllDeploymentCostUntilDate(address, targetDate); + + expect(result).toBe(0); + }); + + it("returns 0 when no deployments found", async () => { + const { service, address, targetDate } = await setupCalculateCost({ + currentHeight: CURRENT_HEIGHT, + deployments: [] + }); + + const result = await service.calculateAllDeploymentCostUntilDate(address, targetDate); + + expect(result).toBe(0); + }); + + it("excludes deployments with null predictedClosedHeight", async () => { + const deployments = [{ predictedClosedHeight: null as unknown as number, blockRate: BLOCK_RATE_1 }]; + + const { service, address, targetDate } = await setupCalculateCost({ + currentHeight: CURRENT_HEIGHT, + deployments + }); + + const result = await service.calculateAllDeploymentCostUntilDate(address, targetDate); + + expect(result).toBe(0); + }); + + it("excludes deployments closing before currentHeight", async () => { + // Given: deployment closes 100 blocks before current height + const deployments = [{ predictedClosedHeight: CURRENT_HEIGHT - 100, blockRate: BLOCK_RATE_1 }]; + + const { service, address, targetDate } = await setupCalculateCost({ + currentHeight: CURRENT_HEIGHT, + deployments + }); + + const result = await service.calculateAllDeploymentCostUntilDate(address, targetDate); + + expect(result).toBe(0); + }); + + it("excludes deployments closing after targetHeight", async () => { + // Given: deployment closes way after target date (2M blocks later) + const deployments = [{ predictedClosedHeight: CURRENT_HEIGHT + 2000000, blockRate: BLOCK_RATE_1 }]; + + const { service, address, targetDate } = await setupCalculateCost({ + currentHeight: CURRENT_HEIGHT, + deployments + }); + + const result = await service.calculateAllDeploymentCostUntilDate(address, targetDate); + + expect(result).toBe(0); + }); + + async function setupCalculateCost(input: { + currentHeight?: number; + userWallet?: ReturnType | undefined; + deployments: Array<{ predictedClosedHeight: number | null; blockRate: number }>; + }) { + const currentHeight = input.currentHeight ?? CURRENT_HEIGHT; + const address = createAkashAddress(); + const userWallet = "userWallet" in input ? input.userWallet : UserWalletSeeder.create({ address }); + const now = new Date(); + const targetDate = addWeeks(now, 1); + + const baseSetup = setup({ currentHeight }); + baseSetup.userWalletRepository.findOneBy.mockResolvedValue(userWallet); + + const deployments = input.deployments; + const deploymentSettings = AutoTopUpDeploymentSeeder.createMany(deployments.length, { address }); + + const drainingDeployments = deploymentSettings.map((setting, idx) => { + const deployment = deployments[idx]; + const predictedClosedHeight = deployment?.predictedClosedHeight ?? undefined; + return DrainingDeploymentSeeder.create({ + dseq: Number(setting.dseq), + owner: address, + predictedClosedHeight: predictedClosedHeight === null ? undefined : predictedClosedHeight, + blockRate: deployment?.blockRate ?? 0 + }); + }); + + baseSetup.deploymentSettingRepository.paginateAutoTopUpDeployments.mockImplementation((_params: { address?: string; limit: number }) => + (async function* () { + yield deploymentSettings; + })() + ); + + baseSetup.leaseRepository.findManyByDseqAndOwner.mockResolvedValue(drainingDeployments); + + return { + ...baseSetup, + address, + targetDate + }; + } + }); + + function setup(input?: { currentHeight?: number }) { + const CURRENT_BLOCK_HEIGHT = 7481457; + const currentHeight = input?.currentHeight ?? CURRENT_BLOCK_HEIGHT; + + const blockHttpService = mock(); + blockHttpService.getCurrentHeight.mockResolvedValue(currentHeight); + + const leaseRepository = mock(); + const userWalletRepository = mock(); + userWalletRepository.findOneBy.mockResolvedValue(undefined); + const deploymentSettingRepository = mock(); + + const config = mockConfigService({ + AUTO_TOP_UP_JOB_INTERVAL_IN_H: 1, + AUTO_TOP_UP_DEPLOYMENT_INTERVAL_IN_H: 3 + }); + + const service = new DrainingDeploymentService(blockHttpService, leaseRepository, userWalletRepository, deploymentSettingRepository, config); + + return { + service, + blockHttpService, + leaseRepository, + userWalletRepository, + deploymentSettingRepository, + config + }; + } }); diff --git a/apps/api/src/deployment/services/draining-deployment/draining-deployment.service.ts b/apps/api/src/deployment/services/draining-deployment/draining-deployment.service.ts index 0d8a2973b7..f9ac118469 100644 --- a/apps/api/src/deployment/services/draining-deployment/draining-deployment.service.ts +++ b/apps/api/src/deployment/services/draining-deployment/draining-deployment.service.ts @@ -84,4 +84,52 @@ export class DrainingDeploymentService { async calculateTopUpAmount(deployment: Pick): Promise { return Math.floor(deployment.blockRate * (averageBlockCountInAnHour * this.config.get("AUTO_TOP_UP_DEPLOYMENT_INTERVAL_IN_H"))); } + + /** + * Calculates the total cost for all deployments that would close before the target date. + * This is based on each deployment's block rate and the number of blocks needed to keep them running until the target date. + * + * @param address - The address to calculate the deployment costs for + * @param targetDate - The target date to calculate the costs until + * @returns The total cost (in credits) needed to keep all draining deployments running until the target date + */ + async calculateAllDeploymentCostUntilDate(address: string, targetDate: Date): Promise { + const userWallet = await this.userWalletRepository.findOneBy({ address }); + + if (!userWallet || !userWallet.address) { + return 0; + } + + const currentHeight = await this.blockHttpService.getCurrentHeight(); + const now = new Date(); + const hoursUntilTarget = (targetDate.getTime() - now.getTime()) / (1000 * 60 * 60); + const targetHeight = Math.floor(currentHeight + averageBlockCountInAnHour * hoursUntilTarget); + + let totalAmount = 0; + + for await (const deploymentSettings of this.deploymentSettingRepository.paginateAutoTopUpDeployments({ address, limit: 100 })) { + if (deploymentSettings.length === 0) { + continue; + } + + const drainingDeployments = await this.leaseRepository.findManyByDseqAndOwner( + targetHeight, + deploymentSettings.map(deployment => ({ dseq: deployment.dseq, owner: deployment.address })) + ); + + if (drainingDeployments.length === 0) { + continue; + } + + for (const { predictedClosedHeight, blockRate } of drainingDeployments) { + if (predictedClosedHeight && predictedClosedHeight >= currentHeight && predictedClosedHeight <= targetHeight) { + const blocksNeeded = targetHeight - currentHeight; + const amountNeeded = Math.floor(blockRate * blocksNeeded); + totalAmount += amountNeeded; + } + } + } + + return totalAmount; + } } diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index b5a94c4b59..97fa17b708 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -13213,6 +13213,158 @@ exports[`API Docs GET /v1/doc returns docs with all routes expected 1`] = ` }, }, "/v1/stripe/payment-methods/default": { + "get": { + "description": "Retrieves the default payment method associated with the current user's account, including card details, validation status, and billing information.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": { + "billing_details": { + "properties": { + "address": { + "nullable": true, + "properties": { + "city": { + "nullable": true, + "type": "string", + }, + "country": { + "nullable": true, + "type": "string", + }, + "line1": { + "nullable": true, + "type": "string", + }, + "line2": { + "nullable": true, + "type": "string", + }, + "postal_code": { + "nullable": true, + "type": "string", + }, + "state": { + "nullable": true, + "type": "string", + }, + }, + "required": [ + "city", + "country", + "line1", + "line2", + "postal_code", + "state", + ], + "type": "object", + }, + "email": { + "nullable": true, + "type": "string", + }, + "name": { + "nullable": true, + "type": "string", + }, + "phone": { + "nullable": true, + "type": "string", + }, + }, + "type": "object", + }, + "card": { + "nullable": true, + "properties": { + "brand": { + "nullable": true, + "type": "string", + }, + "country": { + "nullable": true, + "type": "string", + }, + "exp_month": { + "type": "number", + }, + "exp_year": { + "type": "number", + }, + "funding": { + "nullable": true, + "type": "string", + }, + "last4": { + "nullable": true, + "type": "string", + }, + "network": { + "nullable": true, + "type": "string", + }, + "three_d_secure_usage": { + "nullable": true, + "properties": { + "supported": { + "nullable": true, + "type": "boolean", + }, + }, + "type": "object", + }, + }, + "required": [ + "brand", + "last4", + "exp_month", + "exp_year", + ], + "type": "object", + }, + "type": { + "type": "string", + }, + "validated": { + "type": "boolean", + }, + }, + "required": [ + "type", + ], + "type": "object", + }, + }, + "required": [ + "data", + ], + "type": "object", + }, + }, + }, + "description": "Default payment method retrieved successfully", + }, + "404": { + "description": "Default payment method not found", + }, + }, + "security": [ + { + "BearerAuth": [], + }, + { + "ApiKeyAuth": [], + }, + ], + "summary": "Get the default payment method for the current user", + "tags": [ + "Payment", + ], + }, "post": { "requestBody": { "content": { From 96a01c2dd402d691189d075a6fd7019a54df9d44 Mon Sep 17 00:00:00 2001 From: Iaroslav Gryshaiev Date: Tue, 2 Dec 2025 18:03:47 +0100 Subject: [PATCH 6/9] fix(billing): uses non-memoized balance for auto-reload refs #1779 --- .../controllers/wallet/wallet.controller.ts | 2 +- .../services/balances/balances.service.ts | 10 +++++++++- .../billing/services/stripe/stripe.service.ts | 18 +++++++++++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/api/src/billing/controllers/wallet/wallet.controller.ts b/apps/api/src/billing/controllers/wallet/wallet.controller.ts index 7f3035d944..c933770629 100644 --- a/apps/api/src/billing/controllers/wallet/wallet.controller.ts +++ b/apps/api/src/billing/controllers/wallet/wallet.controller.ts @@ -107,7 +107,7 @@ export class WalletController { isOldWallet = userWallet.isOldWallet ?? false; } - return this.balancesService.getFullBalance(currentAddress, isOldWallet); + return this.balancesService.getFullBalanceMemoized(currentAddress, isOldWallet); } @Protected([{ action: "sign", subject: "UserWallet" }]) diff --git a/apps/api/src/billing/services/balances/balances.service.ts b/apps/api/src/billing/services/balances/balances.service.ts index 0bd8b18cf1..57ea35ae4a 100644 --- a/apps/api/src/billing/services/balances/balances.service.ts +++ b/apps/api/src/billing/services/balances/balances.service.ts @@ -94,6 +94,10 @@ export class BalancesService { } @Memoize({ ttlInSeconds: averageBlockTime }) + async getFullBalanceMemoized(address: string, isOldWallet: boolean = false): Promise { + return this.getFullBalance(address, isOldWallet); + } + async getFullBalance(address: string, isOldWallet: boolean = false): Promise { const [balanceData, deploymentEscrowBalance] = await Promise.all([ this.getFreshLimits({ address, isOldWallet }), @@ -110,8 +114,12 @@ export class BalancesService { } @Memoize({ ttlInSeconds: averageBlockTime }) + async getFullBalanceInFiatMemoized(address: string, isOldWallet: boolean = false): Promise { + return this.getFullBalanceInFiat(address, isOldWallet); + } + async getFullBalanceInFiat(address: string, isOldWallet: boolean = false): Promise { - const { data } = await this.getFullBalance(address, isOldWallet); + const { data } = await this.getFullBalanceMemoized(address, isOldWallet); const balance = await this.toFiatAmount(data.balance); const deployments = await this.toFiatAmount(data.deployments); diff --git a/apps/api/src/billing/services/stripe/stripe.service.ts b/apps/api/src/billing/services/stripe/stripe.service.ts index 425956f52a..678f25268f 100644 --- a/apps/api/src/billing/services/stripe/stripe.service.ts +++ b/apps/api/src/billing/services/stripe/stripe.service.ts @@ -129,18 +129,26 @@ export class StripeService extends Stripe { assert(!customer.deleted, 402, "Payment account has been deleted"); - const remote = customer.invoice_settings.default_payment_method as Stripe.PaymentMethod; + const remote = customer.invoice_settings.default_payment_method; - if (remote && local) { + if (typeof remote === "object" && remote && local) { return { ...remote, validated: local.isValidated }; } } async hasPaymentMethod(paymentMethodId: string, user: UserOutput): Promise { - const paymentMethod = await this.paymentMethods.retrieve(paymentMethodId); - const customerId = typeof paymentMethod.customer === "string" ? paymentMethod.customer : paymentMethod.customer?.id; + try { + const paymentMethod = await this.paymentMethods.retrieve(paymentMethodId); + const customerId = typeof paymentMethod.customer === "string" ? paymentMethod.customer : paymentMethod.customer?.id; + + return customerId === user.stripeCustomerId; + } catch (error: unknown) { + if (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === "resource_missing") { + return false; + } - return customerId === user.stripeCustomerId; + throw error; + } } @WithTransaction() From d7864d01d6f8614cfcaf7db2f8e73efbd35f98ef Mon Sep 17 00:00:00 2001 From: Iaroslav Gryshaiev Date: Tue, 2 Dec 2025 18:10:16 +0100 Subject: [PATCH 7/9] feat(billing): logs payment method out of sync error refs #1779 --- .../src/billing/services/stripe/stripe.service.spec.ts | 3 ++- apps/api/src/billing/services/stripe/stripe.service.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/api/src/billing/services/stripe/stripe.service.spec.ts b/apps/api/src/billing/services/stripe/stripe.service.spec.ts index 8d1e92298b..4ba6e0295e 100644 --- a/apps/api/src/billing/services/stripe/stripe.service.spec.ts +++ b/apps/api/src/billing/services/stripe/stripe.service.spec.ts @@ -4,6 +4,7 @@ import type Stripe from "stripe"; import type { PaymentMethodRepository } from "@src/billing/repositories"; import type { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; import type { RefillService } from "@src/billing/services/refill/refill.service"; +import type { LoggerService } from "@src/core/providers/logging.provider"; import type { UserRepository } from "@src/user/repositories"; import { StripeService } from "./stripe.service"; @@ -1418,7 +1419,7 @@ function setup( const refillService = mock(); const paymentMethodRepository = mock(); - const service = new StripeService(billingConfig, userRepository, refillService, paymentMethodRepository); + const service = new StripeService(billingConfig, userRepository, refillService, paymentMethodRepository, mock()); const stripeData = StripeSeederCreate(); // Store the last user for correct mocking diff --git a/apps/api/src/billing/services/stripe/stripe.service.ts b/apps/api/src/billing/services/stripe/stripe.service.ts index 678f25268f..254b1f5913 100644 --- a/apps/api/src/billing/services/stripe/stripe.service.ts +++ b/apps/api/src/billing/services/stripe/stripe.service.ts @@ -40,8 +40,10 @@ export class StripeService extends Stripe { private readonly billingConfig: BillingConfigService, private readonly userRepository: UserRepository, private readonly refillService: RefillService, - private readonly paymentMethodRepository: PaymentMethodRepository + private readonly paymentMethodRepository: PaymentMethodRepository, + private readonly loggerService: LoggerService ) { + loggerService.setContext(StripeService.name); const secretKey = billingConfig.get("STRIPE_SECRET_KEY"); super(secretKey, { apiVersion: "2025-10-29.clover" @@ -133,6 +135,11 @@ export class StripeService extends Stripe { if (typeof remote === "object" && remote && local) { return { ...remote, validated: local.isValidated }; + } else if (!local || !remote) { + this.loggerService.error({ + event: "STRIPE_PAYMENT_METHOD_OUT_OF_SYNC", + userId: user.id + }); } } From de560e9f86780aeda130a0f193264c527fdba5f6 Mon Sep 17 00:00:00 2001 From: Iaroslav Gryshaiev Date: Wed, 3 Dec 2025 10:34:12 +0100 Subject: [PATCH 8/9] fix(billing): fixes query joins refs #1779 --- .../services/balances/balances.service.ts | 2 +- .../deployment-setting.repository.ts | 22 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/api/src/billing/services/balances/balances.service.ts b/apps/api/src/billing/services/balances/balances.service.ts index 57ea35ae4a..9ae84757fe 100644 --- a/apps/api/src/billing/services/balances/balances.service.ts +++ b/apps/api/src/billing/services/balances/balances.service.ts @@ -119,7 +119,7 @@ export class BalancesService { } async getFullBalanceInFiat(address: string, isOldWallet: boolean = false): Promise { - const { data } = await this.getFullBalanceMemoized(address, isOldWallet); + const { data } = await this.getFullBalance(address, isOldWallet); const balance = await this.toFiatAmount(data.balance); const deployments = await this.toFiatAmount(data.deployments); 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 8b4dde514b..988cc609f6 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 @@ -1,4 +1,4 @@ -import { and, desc, eq, lt } from "drizzle-orm"; +import { and, desc, eq, isNotNull, lt } from "drizzle-orm"; import { last } from "lodash"; import { singleton } from "tsyringe"; @@ -48,11 +48,7 @@ export class DeploymentSettingRepository extends BaseRepository Date: Wed, 3 Dec 2025 11:16:59 +0100 Subject: [PATCH 9/9] refactor(billing): converts startAfter date to iso string refs #1779 --- ...allet-balance-reload-check.handler.spec.ts | 32 +++++++++++-------- .../wallet-balance-reload-check.handler.ts | 2 +- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.spec.ts b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.spec.ts index d3ccde1031..c026e04af0 100644 --- a/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.spec.ts +++ b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.spec.ts @@ -39,20 +39,28 @@ describe(WalletBalanceReloadCheckHandler.name, () => { await handler.handle(job, jobMeta); // Verify calculateAllDeploymentCostUntilDate is called with 7 days - expect(drainingDeploymentService.calculateAllDeploymentCostUntilDate).toHaveBeenCalled(); - const calculateCall = drainingDeploymentService.calculateAllDeploymentCostUntilDate.mock.calls[0]; - const reloadTargetDate = calculateCall[1]; const millisecondsInDay = 24 * millisecondsInHour; const expectedReloadDate = addMilliseconds(new Date(), 7 * millisecondsInDay); + expect(drainingDeploymentService.calculateAllDeploymentCostUntilDate).toHaveBeenCalledWith(expect.any(String), expect.any(Date)); + const calculateCall = drainingDeploymentService.calculateAllDeploymentCostUntilDate.mock.calls[0]; + const reloadTargetDate = calculateCall[1] as Date; expect(reloadTargetDate.getTime()).toBeCloseTo(expectedReloadDate.getTime(), -3); // Verify next check is scheduled for 1 day - expect(walletReloadJobService.scheduleForWalletSetting).toHaveBeenCalled(); - const scheduleCall = walletReloadJobService.scheduleForWalletSetting.mock.calls[0]; - const scheduledDate = scheduleCall[1]?.startAfter; - expect(scheduledDate).toBeInstanceOf(Date); const expectedNextCheckDate = addMilliseconds(new Date(), millisecondsInDay); - expect((scheduledDate as Date).getTime()).toBeCloseTo(expectedNextCheckDate.getTime(), -3); + expect(walletReloadJobService.scheduleForWalletSetting).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + userId: expect.any(String) + }), + expect.objectContaining({ + startAfter: expect.any(String), + prevAction: "complete" + }) + ); + const scheduleCall = walletReloadJobService.scheduleForWalletSetting.mock.calls[0]; + const scheduledDate = new Date(scheduleCall[1]?.startAfter as string); + expect(scheduledDate.getTime()).toBeCloseTo(expectedNextCheckDate.getTime(), -3); expect(stripeService.createPaymentIntent).toHaveBeenCalledWith({ customer: expect.any(String), @@ -148,8 +156,7 @@ describe(WalletBalanceReloadCheckHandler.name, () => { ); }); - it("schedules next check and updates job ID", async () => { - const jobId = faker.string.uuid(); + it("schedules next check", async () => { const balance = 50.0; const weeklyCostInDenom = 50_000_000; const weeklyCostInFiat = 50.0; @@ -157,8 +164,7 @@ describe(WalletBalanceReloadCheckHandler.name, () => { const { handler, walletReloadJobService, walletSetting, job, jobMeta } = setup({ balance: { total: balance }, weeklyCostInDenom, - weeklyCostInFiat, - jobId + weeklyCostInFiat }); await handler.handle(job, jobMeta); @@ -169,7 +175,7 @@ describe(WalletBalanceReloadCheckHandler.name, () => { userId: job.userId }), expect.objectContaining({ - startAfter: expect.any(Date), + startAfter: expect.any(String), prevAction: "complete" }) ); diff --git a/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts index 524d241aa9..4e61072e11 100644 --- a/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts +++ b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.ts @@ -211,7 +211,7 @@ export class WalletBalanceReloadCheckHandler implements JobHandler { try { await this.walletReloadJobService.scheduleForWalletSetting(resources.walletSetting, { - startAfter: this.#calculateNextCheckDate(), + startAfter: this.#calculateNextCheckDate().toISOString(), prevAction: "complete" }); } catch (error) {