diff --git a/apps/api/src/billing/http-schemas/usage.schema.spec.ts b/apps/api/src/billing/http-schemas/usage.schema.spec.ts new file mode 100644 index 0000000000..5b9a18949a --- /dev/null +++ b/apps/api/src/billing/http-schemas/usage.schema.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { GetUsageHistoryQuerySchema } from "./usage.schema"; + +describe("Usage Schema", () => { + describe("GetUsageHistoryQuerySchema", () => { + const address = "akash18andxgtd6r08zzfpcdqg9pdr6smks7gv76tyt6"; + + it("derives startDate as 30 days before the provided endDate", () => { + const result = GetUsageHistoryQuerySchema.parse({ address, endDate: "2024-01-31" }); + + expect(result.startDate).toBe("2024-01-01"); + expect(result.endDate).toBe("2024-01-31"); + }); + + it("derives startDate across month and year boundaries", () => { + const result = GetUsageHistoryQuerySchema.parse({ address, endDate: "2024-01-15" }); + + expect(result.startDate).toBe("2023-12-16"); + expect(result.endDate).toBe("2024-01-15"); + }); + + it("computes startDate in UTC regardless of the process timezone", () => { + const originalTimezone = process.env.TZ; + process.env.TZ = "America/New_York"; + + try { + const result = GetUsageHistoryQuerySchema.parse({ address, endDate: "2024-11-15" }); + + expect(result.startDate).toBe("2024-10-16"); + expect(result.endDate).toBe("2024-11-15"); + } finally { + process.env.TZ = originalTimezone; + } + }); + + it("keeps the provided startDate untouched", () => { + const result = GetUsageHistoryQuerySchema.parse({ address, startDate: "2024-01-01", endDate: "2024-01-31" }); + + expect(result.startDate).toBe("2024-01-01"); + expect(result.endDate).toBe("2024-01-31"); + }); + + it("defaults endDate to today and spans a 30-day window when both dates are omitted", () => { + const result = GetUsageHistoryQuerySchema.parse({ address }); + + expect(result.endDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + const spanInDays = (Date.parse(result.endDate) - Date.parse(result.startDate)) / (24 * 60 * 60 * 1000); + expect(spanInDays).toBe(30); + }); + + it("rejects a range wider than 366 days", () => { + expect(() => GetUsageHistoryQuerySchema.parse({ address, startDate: "2023-01-01", endDate: "2024-12-31" })).toThrow( + "Date range cannot exceed 366 days and startDate must be before endDate" + ); + }); + + it("rejects a startDate after the endDate", () => { + expect(() => GetUsageHistoryQuerySchema.parse({ address, startDate: "2024-02-01", endDate: "2024-01-01" })).toThrow( + "Date range cannot exceed 366 days and startDate must be before endDate" + ); + }); + }); +}); diff --git a/apps/api/src/billing/http-schemas/usage.schema.ts b/apps/api/src/billing/http-schemas/usage.schema.ts index a716fa0f74..3d3d92729b 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}T00:00:00.000Z`); + startDate.setUTCDate(startDate.getUTCDate() - 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 ba29d7916f..9bab196ccf 100644 --- a/apps/api/src/billing/http-schemas/wallet.schema.ts +++ b/apps/api/src/billing/http-schemas/wallet.schema.ts @@ -9,14 +9,14 @@ const AUTO_RELOAD_THRESHOLD_MAX_USD = 10_000; const AUTO_RELOAD_AMOUNT_MAX_USD = 10_000; 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 c8d96f5002..40da355fbc 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,16 @@ export type UserWalletOutput = Omit & { address: string }; + +export function isWalletInitialized(wallet: UserWalletOutput): wallet is WalletInitialized { + return !!wallet.address; +} + export interface UserWalletPublicOutput { id: UserWalletOutput["id"]; userId: UserWalletOutput["userId"]; - address: UserWalletOutput["address"]; + address: WalletInitialized["address"]; creditAmount: UserWalletOutput["creditAmount"]; isTrialing: boolean; createdAt: UserWalletOutput["createdAt"]; @@ -165,7 +171,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.spec.ts b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts index 7992f92955..7897ef8807 100644 --- a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts +++ b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts @@ -55,7 +55,7 @@ describe(WalletInitializerService.name, () => { await di.resolve(WalletInitializerService).initializeAndGrantTrialLimits(userId); expect(managedUserWalletService.createWallet).toHaveBeenCalledWith({ addressIndex: orphanWallet.id }); - expect(updateWalletById).toHaveBeenCalledWith(orphanWallet.id, { address: derivedAddress }, { returning: true }); + expect(updateWalletById).toHaveBeenCalledWith(orphanWallet.id, { address: derivedAddress }); }); it("returns the current state without chain calls when the wallet is already activated", async () => { @@ -162,7 +162,25 @@ describe(WalletInitializerService.name, () => { expect(getOrCreateWallet).toHaveBeenCalledWith({ userId }); expect(managedUserWalletService.createWallet).toHaveBeenCalledWith({ addressIndex: bareWallet.id }); - expect(updateWalletById).toHaveBeenCalledWith(bareWallet.id, { address: derivedAddress }, { returning: true }); + expect(updateWalletById).toHaveBeenCalledWith(bareWallet.id, { address: derivedAddress }); + expect(result.address).toBe(derivedAddress); + }); + + it("derives an address when the existing wallet has an empty-string address", async () => { + const userId = "test-user-id"; + const emptyAddressWallet = createUserWallet({ userId, address: "" }); + const derivedAddress = "akash1derived"; + const getOrCreateWallet = vi.fn().mockResolvedValue({ wallet: emptyAddressWallet, isNew: false }); + const updateWalletById = vi.fn().mockImplementation(async (id, patch) => ({ ...emptyAddressWallet, ...patch })); + + const di = setup({ getOrCreateWallet, updateWalletById }); + const managedUserWalletService = di.resolve(ManagedUserWalletService) as MockProxy; + managedUserWalletService.createWallet.mockResolvedValue({ address: derivedAddress }); + + const result = await di.resolve(WalletInitializerService).ensureWallet(userId); + + expect(managedUserWalletService.createWallet).toHaveBeenCalledWith({ addressIndex: emptyAddressWallet.id }); + expect(updateWalletById).toHaveBeenCalledWith(emptyAddressWallet.id, { address: derivedAddress }); expect(result.address).toBe(derivedAddress); }); 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 d74c291be1..201b31dc29 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 assert from "http-assert"; import { singleton } from "tsyringe"; import { TrialStarted } from "@src/billing/events/trial-started"; -import { UserWalletOutput, UserWalletPublicOutput, UserWalletRepository } from "@src/billing/repositories"; +import { isWalletInitialized, type UserWalletPublicOutput, UserWalletRepository, type WalletInitialized } from "@src/billing/repositories"; import { TrialActivationInstrumentationService } from "@src/billing/services/activate-trial/trial-activation-instrumentation.service"; import { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service"; import { StripeService } from "@src/billing/services/stripe/stripe.service"; @@ -68,27 +68,21 @@ export class WalletInitializerService { await this.domainEvents.publish(new TrialStarted({ userId })); this.trialActivationInstrumentation.recordActivated(userId, Date.now() - new Date(activatedWallet.createdAt).getTime()); - 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 { - return this.#ensureWalletVia(this.userWalletRepository, userId); - } - - /** * Concurrent calls may both derive the address, but derivation is deterministic per wallet id, * so the two updates write the same value and the operation stays idempotent. */ - async #ensureWalletVia(repository: UserWalletRepository, userId: string): Promise { - const { wallet } = await repository.getOrCreate({ userId }); - - if (wallet.address) return wallet; + async ensureWallet(userId: string): Promise { + const { wallet } = await this.userWalletRepository.getOrCreate({ userId }); + if (isWalletInitialized(wallet)) return wallet; const { address } = await this.walletManager.createWallet({ addressIndex: wallet.id }); - return await this.userWalletRepository.updateById(wallet.id, { address }, { returning: true }); + await this.userWalletRepository.updateById(wallet.id, { address }); + return { ...wallet, address }; } } diff --git a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.spec.ts b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.spec.ts index a6d4ddd49c..c53747b1dd 100644 --- a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.spec.ts +++ b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.spec.ts @@ -21,6 +21,16 @@ describe(WalletReaderService.name, () => { expect(result[0].address).toBe(activatedWallet.address); }); + it("excludes activated wallets with an empty-string address", async () => { + const userId = "test-user-id"; + const emptyAddressWallet = createUserWallet({ userId, activatedAt: new Date(), address: "" }); + const { service } = setup({ wallets: [emptyAddressWallet] }); + + const result = await service.getWallets({ userId }); + + expect(result).toEqual([]); + }); + it("returns an empty list when the user only has a non-activated wallet", async () => { const userId = "test-user-id"; const nonActivatedWallet = createUserWallet({ userId, activatedAt: null }); 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..7a30396106 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,18 @@ 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 { + isWalletInitialized, + type UserWalletOutput, + type UserWalletPublicOutput, + UserWalletRepository, + type 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 +25,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 && isWalletInitialized(wallet)) + .map(wallet => this.userWalletRepository.toPublic(wallet)); } async getWalletByUserId(userId: string): Promise; @@ -37,12 +43,8 @@ export class WalletReaderService { return userWallet; } - const { address } = userWallet; - assert(address, 403, "UserWallet is not initialized"); + assert(isWalletInitialized(userWallet), 403, "UserWallet is not initialized"); - return { - ...userWallet, - address - }; + return userWallet; } } 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 4932ad3f54..540be28dc6 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 { LoggerService } from "@src/core"; import type { GetDeploymentResponse } from "@src/deployment/http-schemas/deployment.schema"; import type { SdlService } from "@src/deployment/services/sdl/sdl.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 271a1822ab..d0b0ca5b03 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 type { 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 { LoggerService } from "@src/core"; import { CreateDeploymentRequest, 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 ef3246c817..ed7f275650 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": "Ensures the user's managed wallet exists and enqueues background trial activation. Kept for backward compatibility; trial activation now runs server-side off registration/verification.", "tags": [ "Wallet" ], @@ -46,7 +46,7 @@ }, "responses": { "200": { - "description": "Trial started successfully and wallet created", + "description": "Wallet ensured and trial activation enqueued", "content": { "application/json": { "schema": { @@ -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" @@ -238,19 +159,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 +181,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" @@ -1533,6 +1450,10 @@ }, "awaitResolved": { "type": "boolean" + }, + "idempotencyKey": { + "type": "string", + "format": "uuid" } }, "required": [ @@ -1917,7 +1838,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" }, @@ -2043,7 +1963,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" }, @@ -2180,6 +2099,11 @@ "githubUsername": { "type": "string", "nullable": true + }, + "onboardingSkippedAt": { + "type": "string", + "nullable": true, + "format": "date-time" } }, "required": [ @@ -2268,6 +2192,11 @@ "githubUsername": { "type": "string", "nullable": true + }, + "onboardingSkippedAt": { + "type": "string", + "nullable": true, + "format": "date-time" } }, "required": [ @@ -2468,6 +2397,30 @@ } } }, + "/v1/user/skipOnboarding": { + "post": { + "summary": "Skip onboarding", + "tags": [ + "Users" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "responses": { + "204": { + "description": "Onboarding skipped" + }, + "401": { + "description": "Unauthorized" + } + } + } + }, "/v1/user/template/{id}": { "get": { "summary": "Get template by ID", @@ -22675,4 +22628,4 @@ } } } -} +} \ No newline at end of file diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index 9706c27a45..b17ef524f4 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -13414,7 +13414,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "additionalProperties": false, "properties": { "address": { - "nullable": true, "type": "string", }, "clientSecret": { @@ -13422,7 +13421,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "string", }, "createdAt": { - "nullable": true, "type": "string", }, "creditAmount": { @@ -13432,7 +13430,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "string", }, "id": { - "nullable": true, "type": "number", }, "isTrialing": { @@ -13454,7 +13451,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "number", }, "userId": { - "nullable": true, "type": "string", }, }, @@ -14016,7 +14012,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", @@ -14142,7 +14137,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", @@ -15378,7 +15372,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "items": { "properties": { "address": { - "nullable": true, "type": "string", }, "clientSecret": { @@ -15386,7 +15379,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "string", }, "createdAt": { - "nullable": true, "type": "string", }, "creditAmount": { @@ -15396,7 +15388,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "string", }, "id": { - "nullable": true, "type": "number", }, "isTrialing": { @@ -15418,7 +15409,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/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 da44af4118..fbe0381592 100644 --- a/apps/deploy-web/src/hooks/useManagedWallet.ts +++ b/apps/deploy-web/src/hooks/useManagedWallet.ts @@ -29,15 +29,8 @@ export const useManagedWallet = () => { const isLoading = isInitialLoading || isCreatingFromAnyInstance; 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 136778d676..77462a9aac 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 Ensures the user's managed wallet exists and enqueues background trial activation. Kept for backward compatibility; trial activation now runs server-side off registration/verification. */ post: { parameters: { @@ -34,7 +34,7 @@ export interface paths { }; }; responses: { - /** @description Trial started successfully and wallet created */ + /** @description Wallet ensured and trial activation enqueued */ 200: { headers: { [name: string]: unknown; @@ -42,40 +42,15 @@ export interface paths { 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; - }; - }; - }; - }; - /** @description 3D Secure authentication required to complete trial setup */ - 202: { - headers: { - [name: string]: unknown; - }; - 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; @@ -120,15 +95,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; @@ -714,7 +689,10 @@ export interface paths { youtubeUsername?: string | null; twitterUsername?: string | null; githubUsername?: string | null; + /** Format: date-time */ + onboardingSkippedAt?: string | null; }; + isNewUser: boolean; }; }; }; @@ -762,6 +740,8 @@ export interface paths { youtubeUsername?: string | null; twitterUsername?: string | null; githubUsername?: string | null; + /** Format: date-time */ + onboardingSkippedAt?: string | null; }; }; }; @@ -964,6 +944,47 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/user/skipOnboarding": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Skip onboarding */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Onboarding skipped */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/user/template/{id}": { parameters: { query?: never; @@ -7613,6 +7634,8 @@ export interface operations { paymentMethodId: string; amount: number; awaitResolved?: boolean; + /** Format: uuid */ + idempotencyKey?: string; }; }; };