diff --git a/apps/api/src/billing/http-schemas/usage.schema.ts b/apps/api/src/billing/http-schemas/usage.schema.ts index a716fa0f74..c1068dcaee 100644 --- a/apps/api/src/billing/http-schemas/usage.schema.ts +++ b/apps/api/src/billing/http-schemas/usage.schema.ts @@ -12,29 +12,27 @@ export const GetUsageHistoryQuerySchema = z description: "Start date (YYYY-MM-DD). Defaults to 30 days before endDate", example: "2024-01-01" }), - endDate: z - .string() - .date() - .default(() => new Date().toISOString().split("T")[0]) - .openapi({ - description: "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", - example: "2024-01-31" - }) + endDate: z.string().date().optional().openapi({ + description: "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", + example: "2024-01-31" + }) }) .transform(data => { + const endDate = data.endDate ?? new Date().toISOString().split("T")[0]; + if (data.startDate) { - return data; + return { ...data, startDate: data.startDate, endDate }; } - const endDate = new Date(data.endDate); - endDate.setDate(endDate.getDate() - 30); + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - 30); - return { ...data, startDate: endDate.toISOString().split("T")[0] }; + return { ...data, startDate: startDate.toISOString().split("T")[0], endDate }; }) .refine( data => { - const end = new Date(data.endDate!); - const start = new Date(data.startDate!); + const end = new Date(data.endDate); + const start = new Date(data.startDate); const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)); diff --git a/apps/api/src/billing/http-schemas/wallet.schema.ts b/apps/api/src/billing/http-schemas/wallet.schema.ts index f9af8c6d0e..20e5582f3b 100644 --- a/apps/api/src/billing/http-schemas/wallet.schema.ts +++ b/apps/api/src/billing/http-schemas/wallet.schema.ts @@ -1,14 +1,14 @@ import { z } from "@hono/zod-openapi"; const WalletOutputSchema = z.object({ - id: z.number().nullable().openapi({}), - userId: z.string().nullable().openapi({}), + id: z.number().openapi({}), + userId: z.string().openapi({}), creditAmount: z.number().openapi({}), - address: z.string().nullable().openapi({}), + address: z.string().openapi({}), denom: z.string().openapi({}), isTrialing: z.boolean(), topUpMinAmountUsd: z.number().openapi({ description: "Minimum USD amount accepted by the next paid top-up for this wallet." }), - createdAt: z.coerce.date().nullable().openapi({}) + createdAt: z.date().openapi({}) }); const WalletWithOptional3DSSchema = WalletOutputSchema.extend({ 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 dc24fe1e16..df6d9de8b0 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 @@ -22,10 +22,12 @@ export type UserWalletOutput = Omit & { address: string }; + export interface UserWalletPublicOutput { id: UserWalletOutput["id"]; userId: UserWalletOutput["userId"]; - address: UserWalletOutput["address"]; + address: WalletInitialized["address"]; creditAmount: UserWalletOutput["creditAmount"]; isTrialing: boolean; createdAt: UserWalletOutput["createdAt"]; @@ -164,7 +166,7 @@ export class UserWalletRepository extends BaseRepository { describe("topUpWallet", () => { @@ -19,7 +19,7 @@ describe(RefillService.name, () => { it("should top up existing activated wallet", async () => { const { service, userWalletRepository, managedUserWalletService, managedSignerService, balancesService, walletInitializerService, analyticsService } = setup(); - const existingWallet = createUserWallet({ userId }); + const existingWallet = createInitializedUserWallet({ userId }); walletInitializerService.ensureWallet.mockResolvedValue(existingWallet); userWalletRepository.claimActivation.mockResolvedValue(undefined); managedUserWalletService.authorizeSpending.mockResolvedValue(); @@ -40,7 +40,7 @@ describe(RefillService.name, () => { it("attaches payment context to the balance_top_up analytics event", async () => { const { service, userWalletRepository, managedUserWalletService, balancesService, analyticsService, walletInitializerService } = setup(); - const existingWallet = createUserWallet({ userId }); + const existingWallet = createInitializedUserWallet({ userId }); walletInitializerService.ensureWallet.mockResolvedValue(existingWallet); userWalletRepository.claimActivation.mockResolvedValue(undefined); managedUserWalletService.authorizeSpending.mockResolvedValue(); @@ -72,7 +72,7 @@ describe(RefillService.name, () => { it("does not end trial when endTrial option is false", async () => { const { service, userWalletRepository, managedUserWalletService, balancesService, walletInitializerService } = setup(); - const existingWallet = createUserWallet({ userId }); + const existingWallet = createInitializedUserWallet({ userId }); walletInitializerService.ensureWallet.mockResolvedValue(existingWallet); userWalletRepository.claimActivation.mockResolvedValue(undefined); managedUserWalletService.authorizeSpending.mockResolvedValue(); @@ -87,7 +87,7 @@ describe(RefillService.name, () => { it("activates a non-activated wallet on first funding", async () => { const { service, userWalletRepository, walletInitializerService, balancesService, managedUserWalletService, managedSignerService, analyticsService } = setup(); - const wallet = createUserWallet({ userId, activatedAt: null }); + const wallet = createInitializedUserWallet({ userId, activatedAt: null }); const activatedWallet = { ...wallet, activatedAt: new Date() }; walletInitializerService.ensureWallet.mockResolvedValue(wallet); userWalletRepository.claimActivation.mockResolvedValue(activatedWallet); 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 74b90c4dee..52b0b20a4c 100644 --- a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts +++ b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts @@ -3,7 +3,7 @@ import { singleton } from "tsyringe"; import { AuthService } from "@src/auth/services/auth.service"; import { TrialStarted } from "@src/billing/events/trial-started"; -import { UserWalletOutput, UserWalletPublicOutput, UserWalletRepository } from "@src/billing/repositories"; +import { type UserWalletOutput, type UserWalletPublicOutput, UserWalletRepository, type WalletInitialized } from "@src/billing/repositories"; import { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service"; import { StripeService } from "@src/billing/services/stripe/stripe.service"; import { DomainEventsService } from "@src/core/services/domain-events/domain-events.service"; @@ -69,14 +69,14 @@ export class WalletInitializerService { await this.domainEvents.publish(new TrialStarted({ userId })); - return this.userWalletRepository.toPublic(activatedWallet); + return this.userWalletRepository.toPublic({ ...activatedWallet, address: userWallet.address }); } /** * Idempotently guarantees the user has a wallet row with a derived address. * Address derivation is pure (no chain transaction), so this is safe to run on every registration. */ - async ensureWallet(userId: string): Promise { + async ensureWallet(userId: string): Promise { return this.#ensureWalletVia(this.userWalletRepository, userId); } @@ -84,12 +84,13 @@ export class WalletInitializerService { * Concurrent calls may both derive the address, but derivation is deterministic per wallet id, * so the two updates write the same value and the operation stays idempotent. */ - async #ensureWalletVia(repository: UserWalletRepository, userId: string): Promise { + async #ensureWalletVia(repository: UserWalletRepository, userId: string): Promise { const { wallet } = await repository.getOrCreate({ userId }); - if (wallet.address) return wallet; + if (wallet.address) return { ...wallet, address: wallet.address }; const { address } = await this.walletManager.createWallet({ addressIndex: wallet.id }); - return await this.userWalletRepository.updateById(wallet.id, { address }, { returning: true }); + const updatedWallet = await repository.updateById(wallet.id, { address }, { returning: true }); + return { ...updatedWallet, address }; } } diff --git a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts index a7b4b7d256..5839f4c17a 100644 --- a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts +++ b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts @@ -3,14 +3,12 @@ import assert from "http-assert"; import { Lifecycle, scoped } from "tsyringe"; import { AuthService } from "@src/auth/services/auth.service"; -import { UserWalletOutput, UserWalletPublicOutput, UserWalletRepository } from "@src/billing/repositories"; +import { UserWalletOutput, UserWalletPublicOutput, UserWalletRepository, WalletInitialized } from "@src/billing/repositories"; export interface GetWalletOptions { userId: string; } -export type WalletInitialized = Omit & { address: string }; - @scoped(Lifecycle.ResolutionScoped) export class WalletReaderService { constructor( @@ -21,7 +19,9 @@ export class WalletReaderService { async getWallets(query: GetWalletOptions): Promise { const wallets = await this.userWalletRepository.accessibleBy(this.authService.ability, "read").find(query); - return wallets.filter(wallet => wallet.activatedAt).map(wallet => this.userWalletRepository.toPublic(wallet)); + return wallets + .filter((wallet): wallet is WalletInitialized => wallet.activatedAt !== null && !!wallet.address) + .map(wallet => this.userWalletRepository.toPublic(wallet)); } async getWalletByUserId(userId: string): Promise; diff --git a/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.spec.ts b/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.spec.ts index 956c6b2e95..8722904d01 100644 --- a/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.spec.ts +++ b/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.spec.ts @@ -5,7 +5,8 @@ import { AxiosError } from "axios"; import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; -import type { WalletInitialized, WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; +import type { WalletInitialized } from "@src/billing/repositories"; +import type { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; import type { LoggerService } from "@src/core/providers/logging.provider"; import type { FallbackDeploymentReaderService } from "@src/deployment/services/fallback-deployment-reader/fallback-deployment-reader.service"; import type { FallbackLeaseReaderService } from "@src/deployment/services/fallback-lease-reader/fallback-lease-reader.service"; diff --git a/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts b/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts index f0ed3450f6..cfaa86fa39 100644 --- a/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts +++ b/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts @@ -16,7 +16,8 @@ import { InternalServerError } from "http-errors"; import { Op } from "sequelize"; import { singleton } from "tsyringe"; -import { WalletInitialized, WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; +import type { WalletInitialized } from "@src/billing/repositories"; +import { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; import { Memoize } from "@src/caching/helpers"; import { LoggerService } from "@src/core"; import { GetDeploymentResponse, ListDeploymentsItem } from "@src/deployment/http-schemas/deployment.schema"; diff --git a/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.spec.ts b/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.spec.ts index dfce2273bb..290f7f81d7 100644 --- a/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.spec.ts +++ b/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.spec.ts @@ -3,10 +3,11 @@ import { MsgCloseDeployment, MsgCreateDeployment, MsgUpdateDeployment } from "@a import { afterEach, describe, expect, it, vi } from "vitest"; import { mock, type MockProxy } from "vitest-mock-extended"; +import type { WalletInitialized } from "@src/billing/repositories"; import type { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; import type { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service"; import type { RpcMessageService } from "@src/billing/services/rpc-message-service/rpc-message.service"; -import type { WalletInitialized, WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; +import type { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; import type { GetDeploymentResponse } from "@src/deployment/http-schemas/deployment.schema"; import type { SdlService } from "@src/deployment/services/sdl/sdl.service"; import type { ProviderService } from "@src/provider/services/provider/provider.service"; diff --git a/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts b/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts index 31531c1b96..057b1e253c 100644 --- a/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts +++ b/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts @@ -2,11 +2,11 @@ import { manifestToSortedJSON } from "@akashnetwork/chain-sdk"; import assert from "http-assert"; import { singleton } from "tsyringe"; -import { UserWalletOutput } from "@src/billing/repositories"; +import { UserWalletOutput, WalletInitialized } from "@src/billing/repositories"; import { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; import { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service"; import { RpcMessageService } from "@src/billing/services/rpc-message-service/rpc-message.service"; -import { WalletInitialized, WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; +import { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; import { CreateDeploymentRequest, CreateDeploymentResponse, diff --git a/apps/api/src/deployment/services/lease/lease.service.spec.ts b/apps/api/src/deployment/services/lease/lease.service.spec.ts index d0d43ce5a5..9163e13326 100644 --- a/apps/api/src/deployment/services/lease/lease.service.spec.ts +++ b/apps/api/src/deployment/services/lease/lease.service.spec.ts @@ -2,8 +2,9 @@ import type { LeaseHttpService } from "@akashnetwork/http-sdk"; import { describe, expect, it } from "vitest"; import { mock } from "vitest-mock-extended"; +import type { WalletInitialized } from "@src/billing/repositories"; import type { ManagedSignerService, RpcMessageService } from "@src/billing/services"; -import type { WalletInitialized, WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; +import type { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; import type { GetDeploymentResponse } from "@src/deployment/http-schemas/deployment.schema"; import type { DeploymentReaderService } from "@src/deployment/services/deployment-reader/deployment-reader.service"; import type { ProviderService } from "@src/provider/services/provider/provider.service"; diff --git a/apps/api/swagger/openapi.json b/apps/api/swagger/openapi.json index 9255a81341..c895c1aab7 100644 --- a/apps/api/swagger/openapi.json +++ b/apps/api/swagger/openapi.json @@ -14,7 +14,7 @@ "/v1/start-trial": { "post": { "summary": "Start a trial period for a user", - "description": "Creates a managed wallet for a user and initiates a trial period. This endpoint handles payment method validation and may require 3D Secure authentication for certain payment methods. Returns wallet information and trial status.", + "description": "Creates a managed wallet for a user and initiates a trial period. Returns wallet information and trial status.", "tags": [ "Wallet" ], @@ -56,94 +56,16 @@ "type": "object", "properties": { "id": { - "type": "number", - "nullable": true - }, - "userId": { - "type": "string", - "nullable": true - }, - "creditAmount": { "type": "number" }, - "address": { - "type": "string", - "nullable": true - }, - "denom": { - "type": "string" - }, - "isTrialing": { - "type": "boolean" - }, - "topUpMinAmountUsd": { - "type": "number", - "description": "Minimum USD amount accepted by the next paid top-up for this wallet." - }, - "createdAt": { - "type": "string", - "nullable": true - }, - "requires3DS": { - "type": "boolean" - }, - "clientSecret": { - "type": "string", - "nullable": true - }, - "paymentIntentId": { - "type": "string", - "nullable": true - }, - "paymentMethodId": { - "type": "string", - "nullable": true - } - }, - "required": [ - "id", - "userId", - "creditAmount", - "address", - "denom", - "isTrialing", - "topUpMinAmountUsd", - "createdAt" - ], - "additionalProperties": false - } - }, - "required": [ - "data" - ] - } - } - } - }, - "202": { - "description": "3D Secure authentication required to complete trial setup", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "id": { - "type": "number", - "nullable": true - }, "userId": { - "type": "string", - "nullable": true + "type": "string" }, "creditAmount": { "type": "number" }, "address": { - "type": "string", - "nullable": true + "type": "string" }, "denom": { "type": "string" @@ -156,8 +78,7 @@ "description": "Minimum USD amount accepted by the next paid top-up for this wallet." }, "createdAt": { - "type": "string", - "nullable": true + "type": "string" }, "requires3DS": { "type": "boolean" @@ -194,6 +115,9 @@ } } } + }, + "409": { + "description": "Trial provisioning is already in progress for this user; retry shortly" } } } @@ -238,19 +162,16 @@ "type": "object", "properties": { "id": { - "type": "number", - "nullable": true + "type": "number" }, "userId": { - "type": "string", - "nullable": true + "type": "string" }, "creditAmount": { "type": "number" }, "address": { - "type": "string", - "nullable": true + "type": "string" }, "denom": { "type": "string" @@ -263,8 +184,7 @@ "description": "Minimum USD amount accepted by the next paid top-up for this wallet." }, "createdAt": { - "type": "string", - "nullable": true + "type": "string" }, "requires3DS": { "type": "boolean" @@ -1479,6 +1399,10 @@ }, "awaitResolved": { "type": "boolean" + }, + "idempotencyKey": { + "type": "string", + "format": "uuid" } }, "required": [ @@ -1863,7 +1787,6 @@ "schema": { "type": "string", "format": "date", - "default": "2026-07-20", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31" }, @@ -1989,7 +1912,6 @@ "schema": { "type": "string", "format": "date", - "default": "2026-07-20", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31" }, diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index a08ff4ba15..1de89a14d7 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -13405,7 +13405,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "additionalProperties": false, "properties": { "address": { - "nullable": true, "type": "string", }, "clientSecret": { @@ -13413,7 +13412,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "string", }, "createdAt": { - "nullable": true, "type": "string", }, "creditAmount": { @@ -13423,7 +13421,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "string", }, "id": { - "nullable": true, "type": "number", }, "isTrialing": { @@ -13445,7 +13442,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "number", }, "userId": { - "nullable": true, "type": "string", }, }, @@ -14010,7 +14006,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "name": "endDate", "required": false, "schema": { - "default": "2025-07-03", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31", "format": "date", @@ -14136,7 +14131,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "name": "endDate", "required": false, "schema": { - "default": "2025-07-03", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31", "format": "date", @@ -15289,7 +15283,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "items": { "properties": { "address": { - "nullable": true, "type": "string", }, "clientSecret": { @@ -15297,7 +15290,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "string", }, "createdAt": { - "nullable": true, "type": "string", }, "creditAmount": { @@ -15307,7 +15299,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "string", }, "id": { - "nullable": true, "type": "number", }, "isTrialing": { @@ -15329,7 +15320,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "number", }, "userId": { - "nullable": true, "type": "string", }, }, diff --git a/apps/api/test/seeders/user-wallet.seeder.ts b/apps/api/test/seeders/user-wallet.seeder.ts index edf6bff580..1709fc6560 100644 --- a/apps/api/test/seeders/user-wallet.seeder.ts +++ b/apps/api/test/seeders/user-wallet.seeder.ts @@ -1,6 +1,6 @@ import { faker } from "@faker-js/faker"; -import type { UserWalletOutput } from "@src/billing/repositories"; +import type { UserWalletOutput, WalletInitialized } from "@src/billing/repositories"; import { createAkashAddress } from "./akash-address.seeder"; export function createUserWallet({ @@ -27,3 +27,10 @@ export function createUserWallet({ activatedAt }; } + +export function createInitializedUserWallet({ + address = createAkashAddress(), + ...input +}: Partial & { address?: string } = {}): WalletInitialized { + return { ...createUserWallet(input), address }; +} diff --git a/apps/deploy-web/src/hooks/useEnsureTrialStarted.spec.ts b/apps/deploy-web/src/hooks/useEnsureTrialStarted.spec.ts index b3e3ed32d4..d41af8427e 100644 --- a/apps/deploy-web/src/hooks/useEnsureTrialStarted.spec.ts +++ b/apps/deploy-web/src/hooks/useEnsureTrialStarted.spec.ts @@ -25,15 +25,6 @@ describe(useEnsureTrialStarted.name, () => { expect(create).not.toHaveBeenCalled(); }); - it("fires when a wallet row exists but is not yet initialized (no address)", () => { - const create = vi.fn(); - const { dependencies } = setup({ wallet: { address: null } as never, isLoading: false, create }); - - renderHook(() => useEnsureTrialStarted(dependencies)); - - expect(create).toHaveBeenCalledTimes(1); - }); - it("does not fire while another mutation is in flight", () => { const create = vi.fn(); const { dependencies } = setup({ wallet: undefined, isLoading: true, create }); diff --git a/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts b/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts index 69eeceb2cb..0dde5be1d2 100644 --- a/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts +++ b/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts @@ -43,7 +43,7 @@ export type EnsureTrialStartedResult = { */ export const useEnsureTrialStarted = (d = DEPENDENCIES): EnsureTrialStartedResult => { const { wallet, create, isLoading, createError, resetCreate, refetch } = d.useManagedWallet(); - const isWalletReady = !!wallet?.address; + const isWalletReady = !!wallet; const isStartingTrial = d.useIsMutating({ mutationKey: QueryKeys.getManagedWalletCreateMutationKey() }) > 0; useEffect(() => { diff --git a/apps/deploy-web/src/hooks/useManagedWallet.spec.tsx b/apps/deploy-web/src/hooks/useManagedWallet.spec.tsx index 9e86420afe..6d48d4f91d 100644 --- a/apps/deploy-web/src/hooks/useManagedWallet.spec.tsx +++ b/apps/deploy-web/src/hooks/useManagedWallet.spec.tsx @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; import { useCreateManagedWalletMutation } from "@src/queries/useManagedWalletQuery"; -import { getStorageManagedWallet, updateStorageManagedWallet } from "@src/utils/walletUtils"; +import { getStorageManagedWallet } from "@src/utils/walletUtils"; import { useManagedWallet } from "./useManagedWallet"; import { act } from "@testing-library/react"; @@ -34,30 +34,7 @@ describe(useManagedWallet.name, () => { }); }); - it("keeps the stored wallet untouched when the API returns a wallet without an address", async () => { - const userId = "user-guard-merge"; - updateStorageManagedWallet({ userId, address: "akash1existing", creditAmount: 100, isTrialing: true, selected: true }); - - const { result } = setup({ userId, apiWallet: buildApiWallet({ userId, address: null, creditAmount: 0 }) }); - - await vi.waitFor(() => { - expect(result.current.managed.wallet).toBeDefined(); - }); - expect(getStorageManagedWallet(userId)).toMatchObject({ address: "akash1existing", creditAmount: 100, isTrialing: true }); - }); - - it("does not persist a wallet without an address to storage", async () => { - const userId = "user-guard-empty"; - - const { result } = setup({ userId, apiWallet: buildApiWallet({ userId, address: null }) }); - - await vi.waitFor(() => { - expect(result.current.managed.wallet).toBeDefined(); - }); - expect(getStorageManagedWallet(userId)).toBeUndefined(); - }); - - it("persists the queried wallet to storage once it has an address", async () => { + it("persists the queried wallet to storage", async () => { const userId = "user-sync"; setup({ userId, apiWallet: buildApiWallet({ userId, address: "akash1queried", creditAmount: 25 }) }); @@ -82,15 +59,14 @@ describe(useManagedWallet.name, () => { }); }); - /** Mirrors the real API contract: `address` is nullable while a wallet is mid-provisioning, even though the SDK type claims `string`. */ - function buildApiWallet(overrides: { userId: string; address: string | null; creditAmount?: number }) { + function buildApiWallet(overrides: { userId: string; address: string; creditAmount?: number }) { return { ...mock(), isTrialing: true, creditAmount: overrides.creditAmount ?? 0, userId: overrides.userId, address: overrides.address - } as ApiManagedWalletOutput; + }; } function setup(input?: { userId?: string; apiWallet?: ApiManagedWalletOutput; createdWallet?: ApiManagedWalletOutput }) { diff --git a/apps/deploy-web/src/hooks/useManagedWallet.ts b/apps/deploy-web/src/hooks/useManagedWallet.ts index a7e165572b..8449309edf 100644 --- a/apps/deploy-web/src/hooks/useManagedWallet.ts +++ b/apps/deploy-web/src/hooks/useManagedWallet.ts @@ -39,15 +39,8 @@ export const useManagedWallet = () => { }, [signedInUser?.id, queried, created, setIsSignedInWithTrial]); useEffect(() => { - if (!wallet?.address) { - return; - } - - if (isCreated) { - updateStorageManagedWallet({ ...wallet, selected: true }); - } else { - updateStorageManagedWallet(wallet); - } + if (!wallet) return; + updateStorageManagedWallet(isCreated ? { ...wallet, selected: true } : wallet); }, [isCreated, wallet]); useEffect(() => { diff --git a/packages/console-api-types/src/schema.d.ts b/packages/console-api-types/src/schema.d.ts index fd2cb7c16d..07e687eb88 100644 --- a/packages/console-api-types/src/schema.d.ts +++ b/packages/console-api-types/src/schema.d.ts @@ -15,7 +15,7 @@ export interface paths { put?: never; /** * Start a trial period for a user - * @description Creates a managed wallet for a user and initiates a trial period. This endpoint handles payment method validation and may require 3D Secure authentication for certain payment methods. Returns wallet information and trial status. + * @description Creates a managed wallet for a user and initiates a trial period. Returns wallet information and trial status. */ post: { parameters: { @@ -42,15 +42,15 @@ export interface paths { content: { "application/json": { data: { - id: number | null; - userId: string | null; + id: number; + userId: string; creditAmount: number; - address: string | null; + address: string; denom: string; isTrialing: boolean; /** @description Minimum USD amount accepted by the next paid top-up for this wallet. */ topUpMinAmountUsd: number; - createdAt: string | null; + createdAt: string; requires3DS?: boolean; clientSecret?: string | null; paymentIntentId?: string | null; @@ -59,30 +59,12 @@ export interface paths { }; }; }; - /** @description 3D Secure authentication required to complete trial setup */ - 202: { + /** @description Trial provisioning is already in progress for this user; retry shortly */ + 409: { headers: { [name: string]: unknown; }; - content: { - "application/json": { - data: { - id: number | null; - userId: string | null; - creditAmount: number; - address: string | null; - denom: string; - isTrialing: boolean; - /** @description Minimum USD amount accepted by the next paid top-up for this wallet. */ - topUpMinAmountUsd: number; - createdAt: string | null; - requires3DS?: boolean; - clientSecret?: string | null; - paymentIntentId?: string | null; - paymentMethodId?: string | null; - }; - }; - }; + content?: never; }; }; }; @@ -120,15 +102,15 @@ export interface paths { content: { "application/json": { data: { - id: number | null; - userId: string | null; + id: number; + userId: string; creditAmount: number; - address: string | null; + address: string; denom: string; isTrialing: boolean; /** @description Minimum USD amount accepted by the next paid top-up for this wallet. */ topUpMinAmountUsd: number; - createdAt: string | null; + createdAt: string; requires3DS?: boolean; clientSecret?: string | null; paymentIntentId?: string | null; @@ -7593,6 +7575,8 @@ export interface operations { paymentMethodId: string; amount: number; awaitResolved?: boolean; + /** Format: uuid */ + idempotencyKey?: string; }; }; };