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_clumsy_vertigo.sql b/apps/api/drizzle/0023_clumsy_vertigo.sql new file mode 100644 index 0000000000..44a3f8b162 --- /dev/null +++ b/apps/api/drizzle/0023_clumsy_vertigo.sql @@ -0,0 +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;--> 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 new file mode 100644 index 0000000000..c3d59ff759 --- /dev/null +++ b/apps/api/drizzle/meta/0023_snapshot.json @@ -0,0 +1,808 @@ +{ + "id": "f7545314-c294-4889-b79e-89b21973f7f0", + "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 + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_methods_fingerprint_payment_method_id_unique": { + "name": "payment_methods_fingerprint_payment_method_id_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_is_default_unique": { + "name": "payment_methods_user_id_is_default_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"payment_methods\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_fingerprint_idx": { + "name": "payment_methods_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_idx": { + "name": "payment_methods_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_is_validated_idx": { + "name": "payment_methods_user_id_is_validated_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_validated", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_fingerprint_payment_method_id_idx": { + "name": "payment_methods_user_id_fingerprint_payment_method_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_user_id_userSetting_id_fk": { + "name": "payment_methods_user_id_userSetting_id_fk", + "tableFrom": "payment_methods", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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_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..d520051500 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1764065637598, "tag": "0022_lazy_kabuki", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1764597182108, + "tag": "0023_clumsy_vertigo", + "breakpoints": true } ] } \ No newline at end of file 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/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..2afe749cb9 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 } from "@src/billing/http-schemas/stripe.schema"; +import { + CustomerTransactionsCsvExportQuerySchema, + PaymentMethodMarkAsDefaultInput, + PaymentMethodResponse, + PaymentMethodsResponse +} from "@src/billing/http-schemas/stripe.schema"; import { ApplyCouponRequest, ConfirmPaymentRequest, @@ -42,30 +47,46 @@ export class StripeController { return { data: { clientSecret: setupIntent.client_secret } }; } - @Protected([{ action: "read", subject: "StripePayment" }]) - async getPaymentMethods(): Promise<{ data: Stripe.PaymentMethod[] }> { - const { currentUser } = this.authService; + @Protected([{ action: "update", subject: "PaymentMethod" }]) + async markAsDefault(input: PaymentMethodMarkAsDefaultInput): Promise { + const { ability } = this.authService; + const currentUser = this.authService.getCurrentPayingUser(); - if (!currentUser.stripeCustomerId) { - return { data: [] }; + await this.stripe.markPaymentMethodAsDefault(input.data.id, currentUser, ability); + } + + @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: "PaymentMethod" }]) + async getPaymentMethods(): Promise { + const currentUser = this.authService.getCurrentPayingUser({ strict: false }); + + 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/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/http-schemas/stripe.schema.ts b/apps/api/src/billing/http-schemas/stripe.schema.ts index 39f5a89b1f..dd9ec50b64 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(), @@ -52,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(), @@ -227,18 +237,15 @@ 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 PaymentMethodResponse = z.infer; export type ConfirmPaymentRequest = z.infer; export type PaymentIntentResult = z.infer; export type PaymentMethodValidationResult = z.infer; export type ConfirmPaymentResponse = 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..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 @@ -16,11 +16,15 @@ 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() }, 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), 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..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 { sql } from "drizzle-orm"; -import { boolean, index, integer, numeric, pgTable, timestamp, unique, uuid } from "drizzle-orm/pg-core"; +import { relations, sql } from "drizzle-orm"; +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() @@ -35,3 +27,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..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 @@ -35,35 +35,32 @@ 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); - } - protected toOutput(dbOutput: Partial): 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; + return this.toOutput(walletSetting); } - protected toInput(payload: Partial): Partial { - const { autoReloadThreshold, autoReloadAmount, ...input } = payload; - const dbInput: Partial = input as Partial; - - if (autoReloadThreshold !== undefined) { - dbInput.autoReloadThreshold = autoReloadThreshold.toString(); - } + 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 (autoReloadAmount !== undefined) { - dbInput.autoReloadAmount = autoReloadAmount.toString(); - } + if (!walletSetting) return undefined; - return dbInput; + return walletSetting; } } 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..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 @@ -2,6 +2,8 @@ import { container } from "tsyringe"; import { StripeController } from "@src/billing/controllers/stripe/stripe.controller"; import { + PaymentMethodMarkAsDefaultInputSchema, + PaymentMethodResponseSchema, PaymentMethodsResponseSchema, RemovePaymentMethodParamsSchema, SetupIntentResponseSchema, @@ -34,11 +36,68 @@ const setupIntentRoute = createRoute({ } } }); + stripePaymentMethodsRouter.openapi(setupIntentRoute, async function createSetupIntent(c) { const response = await container.resolve(StripeController).createSetupIntent(); return c.json(response, 200); }); +const markAsDefaultRoute = 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(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", @@ -59,6 +118,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 +140,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 +175,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/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) { diff --git a/apps/api/src/billing/services/balances/balances.service.ts b/apps/api/src/billing/services/balances/balances.service.ts index 3c578bf551..9ae84757fe 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 { @@ -92,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 }), @@ -106,4 +112,32 @@ 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 balance = await this.toFiatAmount(data.balance); + const deployments = await this.toFiatAmount(data.deployments); + const total = this.ensure2floatingDigits(balance + deployments); + + return { balance, deployments, total }; + } + + 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); + } + + 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/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.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 63b569fe2d..254b1f5913 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"); @@ -34,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" @@ -97,7 +105,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 +121,82 @@ 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; + + 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 + }); + } + } + + async hasPaymentMethod(paymentMethodId: string, user: UserOutput): Promise { + 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; + } + + throw error; + } + } + + @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, undefined, { timeout: 3_000 }) + ]); + + 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 } + }, + { timeout: 3_000 } + ); + } + async createPaymentIntent(params: { customer: string; payment_method: string; @@ -120,22 +204,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 +406,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/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 new file mode 100644 index 0000000000..c026e04af0 --- /dev/null +++ b/apps/api/src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler.spec.ts @@ -0,0 +1,380 @@ +import { faker } from "@faker-js/faker"; +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 { 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"; + +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 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); + + // Verify calculateAllDeploymentCostUntilDate is called with 7 days + 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 + const expectedNextCheckDate = addMilliseconds(new Date(), millisecondsInDay); + 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), + payment_method: expect.any(String), + amount: expectedReloadAmount, + currency: "usd", + confirm: true, + idempotencyKey: `${WalletBalanceReloadCheck.name}.${jobMeta.id}` + }); + expect(loggerService.info).toHaveBeenCalledWith( + expect.objectContaining({ + event: "WALLET_BALANCE_RELOADED", + balance, + costUntilTargetDateInFiat + }) + ); + }); + + 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(stripeService.createPaymentIntent).toHaveBeenCalledWith({ + customer: expect.any(String), + payment_method: expect.any(String), + amount: expectedReloadAmount, + currency: "usd", + confirm: true, + idempotencyKey: `${WalletBalanceReloadCheck.name}.${jobMeta.id}` + }); + }); + + 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(stripeService.createPaymentIntent).not.toHaveBeenCalled(); + expect(loggerService.info).toHaveBeenCalledWith( + expect.objectContaining({ + event: "WALLET_BALANCE_RELOAD_SKIPPED", + balance, + costUntilTargetDateInFiat + }) + ); + }); + + 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(stripeService.createPaymentIntent).not.toHaveBeenCalled(); + expect(loggerService.info).toHaveBeenCalledWith( + expect.objectContaining({ + event: "WALLET_BALANCE_RELOAD_SKIPPED", + balance, + costUntilTargetDateInFiat + }) + ); + }); + + it("schedules next check", async () => { + 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 + }); + + await handler.handle(job, jobMeta); + + expect(walletReloadJobService.scheduleForWalletSetting).toHaveBeenCalledWith( + expect.objectContaining({ + id: walletSetting.id, + userId: job.userId + }), + expect.objectContaining({ + startAfter: expect.any(String), + 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({ + walletSettingNotFound: true + }); + + await handler.handle(job, jobMeta); + + expect(loggerService.error).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, job, jobMeta } = setup({ + autoReloadEnabled: false + }); + + await handler.handle(job, jobMeta); + + expect(walletSettingRepository.findInternalByUserIdWithRelations).toHaveBeenCalledWith(job.userId); + 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 wallet is not initialized", async () => { + 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.error).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 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.error).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 balance = 15.0; + + const { handler, loggerService, stripeService, job, jobMeta } = setup({ + balance: { total: balance } + }); + stripeService.getDefaultPaymentMethod.mockResolvedValue(undefined); + + await handler.handle(job, jobMeta); + + expect(loggerService.error).toHaveBeenCalledWith({ + event: "DEFAULT_PAYMENT_METHOD_NOT_FOUND", + message: "Default payment method not found", + userId: job.userId + }); + }); + }); + + 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: input?.autoReloadEnabled ?? true + }); + 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 walletReloadJobService = mock(); + const drainingDeploymentService = mock(); + const stripeService = mock(); + const loggerService = mock(); + + 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, + 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..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 @@ -1,7 +1,35 @@ +import { createMongoAbility } from "@casl/ability"; +import { addMilliseconds, millisecondsInHour } from "date-fns"; +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 { 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; +}; + +type InitializedWallet = Require, "address">; +type ActionableWalletSetting = Pick; + +type Resources = { + walletSetting: ActionableWalletSetting; + wallet: InitializedWallet; + user: PayingUser; +}; +type AllResources = Resources & { balance: GetBalancesResponseOutput["data"]["total"]; paymentMethod: PaymentMethod }; + +const millisecondsInDay = 24 * millisecondsInHour; @singleton() export class WalletBalanceReloadCheckHandler implements JobHandler { @@ -11,7 +39,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 { 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, + userId: user.id + }, + wallet: { ...wallet, address }, + user + }); + } + + async #getDefaultPaymentMethod(user: PayingUser): Promise> { + 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.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, + costUntilTargetDateInFiat, + threshold + }; + + if (costUntilTargetDateInFiat === 0 || resources.balance >= threshold) { + this.loggerService.info({ + ...log, + event: "WALLET_BALANCE_RELOAD_SKIPPED" + }); + return; + } + + try { + 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 + }); + throw error; + } + } + + async #scheduleNextCheck(resources: Resources): Promise { + try { + await this.walletReloadJobService.scheduleForWalletSetting(resources.walletSetting, { + startAfter: this.#calculateNextCheckDate().toISOString(), + prevAction: "complete" + }); + } catch (error) { + this.loggerService.error({ + event: "ERROR_SCHEDULING_NEXT_CHECK", + walletAddress: resources.wallet.address, + error: error + }); + throw error; + } + } - async handle(): Promise {} + #calculateNextCheckDate(): Date { + return addMilliseconds(new Date(), this.#CHECK_INTERVAL_IN_MS); + } } 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-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 b9605289e7..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,21 +2,19 @@ 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 { JobQueueService } from "@src/core"; +import type { PaymentMethod, StripeService } from "@src/billing/services/stripe/stripe.service"; +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"; +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"; -jest.mock("uuid"); -const uuidMock = uuidv4 as jest.MockedFn; - describe(WalletSettingService.name, () => { describe("getWalletSetting", () => { it("returns wallet setting when found", async () => { @@ -47,7 +45,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, { @@ -67,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, @@ -77,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, @@ -97,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 () => { @@ -170,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, @@ -180,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 @@ -195,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 = { @@ -228,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 () => { @@ -275,11 +275,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(); @@ -288,22 +295,24 @@ 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 service = new WalletSettingService(walletSettingRepository, userWalletRepository, authService, jobQueueService); + const service = new WalletSettingService(walletSettingRepository, userWalletRepository, userRepository, stripeService, authService, walletReloadJobService); const { autoReloadJobId, ...publicSetting } = walletSetting; return { - user, + user: userWithStripe, userWallet, walletSetting, publicSetting, walletSettingRepository, userWalletRepository, + 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 9c16cc2e19..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,15 +1,15 @@ 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 { 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"; -export interface WalletSetting { +export interface WalletSettingInput { autoReloadEnabled?: boolean; autoReloadThreshold?: number; autoReloadAmount?: number; @@ -20,8 +20,10 @@ 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 walletReloadJobService: WalletReloadJobService ) {} async getWalletSetting(userId: string): Promise | undefined> { @@ -39,41 +41,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 +90,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,49 +104,40 @@ 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.walletReloadJobService.scheduleForWalletSetting(next); } if (!next?.autoReloadEnabled && next?.autoReloadJobId) { - await this.jobQueueService.cancel(WalletBalanceReloadCheck.name, next.autoReloadJobId); - } - } - - private async schedule(walletSetting: WalletSettingOutput) { - if (walletSetting.autoReloadJobId) { - await this.jobQueueService.cancel(WalletBalanceReloadCheck.name, walletSetting.autoReloadJobId); + await this.walletReloadJobService.cancel(next.userId, next.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 { @@ -155,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/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..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 @@ -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,17 @@ 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", + options: { startAfter: expect.any(Date) } + }); expect(result).toBe("job-id-123"); }); @@ -116,8 +131,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 +149,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 +162,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 +186,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 +200,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 +299,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..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 @@ -80,16 +80,20 @@ 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, + options + }); + + return jobId; } async cancel(name: string, id: string): Promise { @@ -101,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."); @@ -139,7 +152,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 +215,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/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..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"; @@ -38,7 +38,7 @@ export class DeploymentSettingRepository extends BaseRepository { + async *paginateAutoTopUpDeployments(options: { address?: string; limit: number }): AsyncGenerator { let lastId: string | undefined; do { @@ -48,7 +48,7 @@ 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 eb891b4dd9..97fa17b708 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -13212,6 +13212,204 @@ 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": { + "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)); }