From 9f06baeae4cdba53d7fc8d6e0789836320ddcb0e Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:59:34 +0100 Subject: [PATCH 1/5] refactor(billing): require wallet address in public wallet API responses --- .../src/billing/http-schemas/wallet.schema.ts | 8 +- .../user-wallet/user-wallet.repository.ts | 6 +- .../services/refill/refill.service.spec.ts | 10 +- .../wallet-initializer.service.ts | 13 +- .../wallet-reader/wallet-reader.service.ts | 8 +- .../deployment-reader.service.spec.ts | 3 +- .../deployment-reader.service.ts | 3 +- .../deployment-writer.service.spec.ts | 3 +- .../deployment-writer.service.ts | 3 +- .../services/lease/lease.service.spec.ts | 3 +- apps/api/swagger/openapi.json | 114 +++--------------- .../__snapshots__/docs.spec.ts.snap | 8 -- apps/api/test/seeders/user-wallet.seeder.ts | 9 +- packages/console-api-types/src/schema.d.ts | 44 +++---- 14 files changed, 75 insertions(+), 160 deletions(-) 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..41219be3ae 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 { UserWalletOutput, UserWalletPublicOutput, UserWalletRepository, 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 this.userWalletRepository.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..63495cf763 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 !== null) + .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..70f9f50028 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 { 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..ca69bdc1d5 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 @@ -3,10 +3,11 @@ import assert from "http-assert"; import { singleton } from "tsyringe"; import { UserWalletOutput } from "@src/billing/repositories"; +import { 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..d9b8397193 100644 --- a/apps/api/swagger/openapi.json +++ b/apps/api/swagger/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "servers": [ { - "url": "https://console-api-sandbox.akash.network" + "url": "http://localhost:3080" } ], "info": { @@ -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,7 @@ "schema": { "type": "string", "format": "date", - "default": "2026-07-20", + "default": "2026-07-24", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31" }, @@ -1989,7 +1913,7 @@ "schema": { "type": "string", "format": "date", - "default": "2026-07-20", + "default": "2026-07-24", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31" }, @@ -22617,4 +22541,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 a08ff4ba15..56b567987a 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", }, }, @@ -15289,7 +15285,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "items": { "properties": { "address": { - "nullable": true, "type": "string", }, "clientSecret": { @@ -15297,7 +15292,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "string", }, "createdAt": { - "nullable": true, "type": "string", }, "creditAmount": { @@ -15307,7 +15301,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "string", }, "id": { - "nullable": true, "type": "number", }, "isTrialing": { @@ -15329,7 +15322,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/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; }; }; }; From cb04b6c03154769e55a6891f2ca5196d22ea5d54 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:00:12 +0100 Subject: [PATCH 2/5] refactor(managed-wallet): drop null-address wallet handling from web consumers --- .../src/hooks/useEnsureTrialStarted.spec.ts | 9 ------ .../src/hooks/useEnsureTrialStarted.ts | 2 +- .../src/hooks/useManagedWallet.spec.tsx | 32 +++---------------- apps/deploy-web/src/hooks/useManagedWallet.ts | 8 ++--- 4 files changed, 7 insertions(+), 44 deletions(-) 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..9e5301af18 100644 --- a/apps/deploy-web/src/hooks/useManagedWallet.ts +++ b/apps/deploy-web/src/hooks/useManagedWallet.ts @@ -39,13 +39,9 @@ export const useManagedWallet = () => { }, [signedInUser?.id, queried, created, setIsSignedInWithTrial]); useEffect(() => { - if (!wallet?.address) { - return; - } - - if (isCreated) { + if (wallet && isCreated) { updateStorageManagedWallet({ ...wallet, selected: true }); - } else { + } else if (wallet) { updateStorageManagedWallet(wallet); } }, [isCreated, wallet]); From aaf18a75f9a150ae9ee6ecd5d1023a07381802b1 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:04:12 +0100 Subject: [PATCH 3/5] refactor(managed-wallet): tidy wallet-address refactor diff - merge duplicated @src/billing/repositories import in deployment-writer - flatten managed-wallet storage-sync effect to an early return + ternary - revert accidental openapi regeneration artifacts (localhost server url, today's date defaults, dropped trailing newline) --- .../deployment-writer/deployment-writer.service.ts | 3 +-- apps/api/swagger/openapi.json | 8 ++++---- apps/deploy-web/src/hooks/useManagedWallet.ts | 7 ++----- 3 files changed, 7 insertions(+), 11 deletions(-) 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 ca69bdc1d5..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,8 +2,7 @@ import { manifestToSortedJSON } from "@akashnetwork/chain-sdk"; import assert from "http-assert"; import { singleton } from "tsyringe"; -import { UserWalletOutput } from "@src/billing/repositories"; -import { WalletInitialized } 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"; diff --git a/apps/api/swagger/openapi.json b/apps/api/swagger/openapi.json index d9b8397193..ea767ef414 100644 --- a/apps/api/swagger/openapi.json +++ b/apps/api/swagger/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "servers": [ { - "url": "http://localhost:3080" + "url": "https://console-api-sandbox.akash.network" } ], "info": { @@ -1787,7 +1787,7 @@ "schema": { "type": "string", "format": "date", - "default": "2026-07-24", + "default": "2026-07-20", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31" }, @@ -1913,7 +1913,7 @@ "schema": { "type": "string", "format": "date", - "default": "2026-07-24", + "default": "2026-07-20", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31" }, @@ -22541,4 +22541,4 @@ } } } -} \ No newline at end of file +} diff --git a/apps/deploy-web/src/hooks/useManagedWallet.ts b/apps/deploy-web/src/hooks/useManagedWallet.ts index 9e5301af18..8449309edf 100644 --- a/apps/deploy-web/src/hooks/useManagedWallet.ts +++ b/apps/deploy-web/src/hooks/useManagedWallet.ts @@ -39,11 +39,8 @@ export const useManagedWallet = () => { }, [signedInUser?.id, queried, created, setIsSignedInWithTrial]); useEffect(() => { - if (wallet && isCreated) { - updateStorageManagedWallet({ ...wallet, selected: true }); - } else if (wallet) { - updateStorageManagedWallet(wallet); - } + if (!wallet) return; + updateStorageManagedWallet(isCreated ? { ...wallet, selected: true } : wallet); }, [isCreated, wallet]); useEffect(() => { From 4adc9f9882bc85bfd37c641bc2b22bd66f991d3d Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:27:15 +0100 Subject: [PATCH 4/5] refactor(billing): scope wallet address mutation and harden readiness guard Addresses review feedback on the wallet-address contract: - #ensureWalletVia now mutates through the CASL-scoped `repository` argument instead of the unscoped `this.userWalletRepository`, so the get/check/mutate sequence stays authorization-scoped during trial init. - getWallets filters activated wallets with a truthiness check on `address` so an empty-string address is also excluded and the `WalletInitialized` predicate holds. - Use type-only imports for the wallet type aliases and WalletInitialized. --- .../services/wallet-initializer/wallet-initializer.service.ts | 4 ++-- .../billing/services/wallet-reader/wallet-reader.service.ts | 2 +- .../services/deployment-reader/deployment-reader.service.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 41219be3ae..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, WalletInitialized } 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"; @@ -90,7 +90,7 @@ export class WalletInitializerService { if (wallet.address) return { ...wallet, address: wallet.address }; const { address } = await this.walletManager.createWallet({ addressIndex: wallet.id }); - const updatedWallet = 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 63495cf763..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 @@ -20,7 +20,7 @@ export class WalletReaderService { const wallets = await this.userWalletRepository.accessibleBy(this.authService.ability, "read").find(query); return wallets - .filter((wallet): wallet is WalletInitialized => wallet.activatedAt !== null && wallet.address !== null) + .filter((wallet): wallet is WalletInitialized => wallet.activatedAt !== null && !!wallet.address) .map(wallet => this.userWalletRepository.toPublic(wallet)); } 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 70f9f50028..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,7 @@ import { InternalServerError } from "http-errors"; import { Op } from "sequelize"; import { singleton } from "tsyringe"; -import { WalletInitialized } from "@src/billing/repositories"; +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"; From 9d75988976a286cc2e4e710c7703b90604fb2471 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:51:35 +0100 Subject: [PATCH 5/5] fix(billing): stop publishing a stale endDate default in the usage OpenAPI spec The usage-history query schema defaulted `endDate` with `.default(() => new Date()...)`. The OpenAPI generator evaluates that at generation time and freezes a static date into the spec, which then goes stale and misleads generated clients into sending an old default date. Move the "default to today" logic into the schema transform (next to the existing startDate-from-endDate derivation) so runtime behavior is unchanged but the generated spec no longer carries a static default. Regenerate openapi.json, update the docs snapshot, and drop the now-redundant non-null assertions in the router and refine. --- .../src/billing/http-schemas/usage.schema.ts | 26 +++++++++---------- .../src/billing/routes/usage/usage.router.ts | 4 +-- apps/api/swagger/openapi.json | 2 -- .../__snapshots__/docs.spec.ts.snap | 2 -- 4 files changed, 14 insertions(+), 20 deletions(-) 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/routes/usage/usage.router.ts b/apps/api/src/billing/routes/usage/usage.router.ts index cffa238deb..f9bc0571ae 100644 --- a/apps/api/src/billing/routes/usage/usage.router.ts +++ b/apps/api/src/billing/routes/usage/usage.router.ts @@ -59,12 +59,12 @@ export const usageRouter = new OpenApiHonoHandler(); usageRouter.openapi(getUsageHistoryRoute, async function routeGetBillingUsage(c) { const { address, startDate, endDate } = c.req.valid("query"); - const usageHistory = await container.resolve(UsageController).getHistory(address, startDate!, endDate!); + const usageHistory = await container.resolve(UsageController).getHistory(address, startDate, endDate); return c.json(usageHistory); }); usageRouter.openapi(getUsageHistoryStatsRoute, async function routeGetBillingOverview(c) { const { address, startDate, endDate } = c.req.valid("query"); - const usageHistoryStats = await container.resolve(UsageController).getHistoryStats(address, startDate!, endDate!); + const usageHistoryStats = await container.resolve(UsageController).getHistoryStats(address, startDate, endDate); return c.json(usageHistoryStats); }); diff --git a/apps/api/swagger/openapi.json b/apps/api/swagger/openapi.json index ea767ef414..c895c1aab7 100644 --- a/apps/api/swagger/openapi.json +++ b/apps/api/swagger/openapi.json @@ -1787,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" }, @@ -1913,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 56b567987a..1de89a14d7 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -14006,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", @@ -14132,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",