Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions apps/api/src/billing/http-schemas/usage.schema.spec.ts
Original file line number Diff line number Diff line change
@@ -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"
);
});
});
});
26 changes: 12 additions & 14 deletions apps/api/src/billing/http-schemas/usage.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
8 changes: 4 additions & 4 deletions apps/api/src/billing/http-schemas/wallet.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,16 @@ export type UserWalletOutput = Omit<DbUserWalletOutput, "feeAllowance" | "deploy
feeAllowance: number;
};

export type WalletInitialized = Omit<UserWalletOutput, "address"> & { 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"];
Expand Down Expand Up @@ -165,7 +171,7 @@ export class UserWalletRepository extends BaseRepository<ApiPgTables["UserWallet
return dbInput;
}

toPublic(output: UserWalletOutput): UserWalletPublicOutput {
toPublic(output: WalletInitialized): UserWalletPublicOutput {
return {
id: output.id,
userId: output.userId,
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/billing/routes/usage/usage.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
10 changes: 5 additions & 5 deletions apps/api/src/billing/services/refill/refill.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { RefillService } from "@src/billing/services/refill/refill.service";
import type { WalletInitializerService } from "@src/billing/services/wallet-initializer/wallet-initializer.service";
import type { AnalyticsService } from "@src/core/services/analytics/analytics.service";

import { createUserWallet } from "@test/seeders/user-wallet.seeder";
import { createInitializedUserWallet, createUserWallet } from "@test/seeders/user-wallet.seeder";

describe(RefillService.name, () => {
describe("topUpWallet", () => {
Expand All @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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>;
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);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Comment thread
baktun14 marked this conversation as resolved.
}

/**
* 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<UserWalletOutput> {
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<UserWalletOutput> {
const { wallet } = await repository.getOrCreate({ userId });

if (wallet.address) return wallet;
async ensureWallet(userId: string): Promise<WalletInitialized> {
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 };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserWalletOutput, "address"> & { address: string };

@scoped(Lifecycle.ResolutionScoped)
export class WalletReaderService {
constructor(
Expand All @@ -21,7 +25,9 @@ export class WalletReaderService {
async getWallets(query: GetWalletOptions): Promise<UserWalletPublicOutput[]> {
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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

async getWalletByUserId(userId: string): Promise<WalletInitialized>;
Comment thread
baktun14 marked this conversation as resolved.
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading