Skip to content
Merged
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
4 changes: 2 additions & 2 deletions apps/api/src/auth/services/ability/ability.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class AbilityService {

private readonly RULES: Record<Role, Array<RawRule & { enabledIf?: FeatureFlagValue }>> = {
REGULAR_USER: [
{ action: ["create", "read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } },
{ action: ["read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } },
{ action: "manage", subject: "WalletSetting", conditions: { userId: "${user.id}" } },
{ action: "read", subject: "User", conditions: { id: "${user.id}" } },
{ action: "verify-email", subject: "User", conditions: { email: "${user.email}" } },
Expand All @@ -28,7 +28,7 @@ export class AbilityService {
{ action: "manage", subject: "NotificationChannel", conditions: { userId: "${user.id}" } }
],
REGULAR_PAYING_USER: [
{ action: ["create", "read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } },
{ action: ["read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } },
{ action: "manage", subject: "WalletSetting", conditions: { userId: "${user.id}" } },
{ action: "read", subject: "User", conditions: { id: "${user.id}" } },
{ action: "verify-email", subject: "User", conditions: { email: "${user.email}" } },
Expand Down
51 changes: 4 additions & 47 deletions apps/api/src/billing/controllers/wallet/wallet.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ import { ManagedSignerService } from "@src/billing/services";
import { BalancesService } from "@src/billing/services/balances/balances.service";
import { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service";
import { RefillService } from "@src/billing/services/refill/refill.service";
import { TrialActivationJobService } from "@src/billing/services/trial-activation-job/trial-activation-job.service";
import { TrialValidationService } from "@src/billing/services/trial-validation/trial-validation.service";
import { WalletInitializerService } from "@src/billing/services/wallet-initializer/wallet-initializer.service";
import { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service";
import type { UserOutput } from "@src/user/repositories";
import { WalletController } from "./wallet.controller";
Expand Down Expand Up @@ -62,54 +60,13 @@ describe("WalletController", () => {
});
});

describe("create", () => {
it("ensures the wallet, enqueues trial activation, and returns the wallet", async () => {
const user = createUser();
const wallet = {
id: faker.number.int(),
userId: user.id,
address: faker.string.alphanumeric(44),
creditAmount: 0,
isTrialing: true,
createdAt: new Date()
};
const container = setup({ user, wallet });
const walletController = container.resolve(WalletController);

const result = await walletController.create({ data: { userId: user.id } });

expect(container.resolve(WalletInitializerService).ensureWallet).toHaveBeenCalledWith(user.id);
expect(container.resolve(TrialActivationJobService).schedule).toHaveBeenCalledWith(user.id);
expect(result).toEqual({ data: { ...wallet, denom: "uakt", topUpMinAmountUsd: 100 } });
});

it("rejects starting a trial for another user without touching their wallet", async () => {
const container = setup({ user: createUser() });
const walletController = container.resolve(WalletController);

await expect(walletController.create({ data: { userId: faker.string.uuid() } })).rejects.toMatchObject({ status: 403 });

expect(container.resolve(WalletInitializerService).ensureWallet).not.toHaveBeenCalled();
expect(container.resolve(TrialActivationJobService).schedule).not.toHaveBeenCalled();
});
});

function setup(input?: { user?: UserOutput; wallets?: UserWalletPublicOutput[]; wallet?: UserWalletPublicOutput }) {
rootContainer.register(AuthService, {
useValue: mock<AuthService>({
ability: createMongoAbility<MongoAbility>([{ action: "create", subject: "UserWallet" }]),
currentUser: input?.user ?? createUser()
})
});
function setup(input?: { user?: UserOutput; wallets?: UserWalletPublicOutput[] }) {
const authService = mock<AuthService>({ currentUser: input?.user ?? createUser() });
authService.ability = createMongoAbility<MongoAbility>([{ action: "read", subject: "UserWallet" }]);
rootContainer.register(AuthService, { useValue: authService });
rootContainer.register(BillingConfigService, {
useValue: mock<BillingConfigService>({ get: vi.fn().mockReturnValue("uakt") })
});
rootContainer.register(WalletInitializerService, {
useValue: mock<WalletInitializerService>({ ensureWallet: vi.fn().mockResolvedValue(input?.wallet) })
});
rootContainer.register(TrialActivationJobService, {
useValue: mock<TrialActivationJobService>({ schedule: vi.fn().mockResolvedValue(undefined) })
});
rootContainer.register(ManagedSignerService, { useValue: mock<ManagedSignerService>() });
rootContainer.register(RefillService, { useValue: mock<RefillService>() });
rootContainer.register(WalletReaderService, {
Expand Down
27 changes: 1 addition & 26 deletions apps/api/src/billing/controllers/wallet/wallet.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,19 @@ import { Lifecycle, scoped } from "tsyringe";
import { AuthService, Protected } from "@src/auth/services/auth.service";
import type { GetBalancesResponseOutput } from "@src/billing/http-schemas/balance.schema";
import type { SignTxRequestInput, SignTxResponseOutput } from "@src/billing/http-schemas/tx.schema";
import type { StartTrialRequestInput, WalletListOutputResponse, WalletOutputResponse } from "@src/billing/http-schemas/wallet.schema";
import type { WalletListOutputResponse } from "@src/billing/http-schemas/wallet.schema";
import { UserWalletRepository } from "@src/billing/repositories";
import type { GetWalletQuery } from "@src/billing/routes/get-wallet-list/get-wallet-list.router";
import { BalancesService } from "@src/billing/services/balances/balances.service";
import { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service";
import { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service";
import { RefillService } from "@src/billing/services/refill/refill.service";
import { TrialActivationJobService } from "@src/billing/services/trial-activation-job/trial-activation-job.service";
import { TrialValidationService } from "@src/billing/services/trial-validation/trial-validation.service";
import { WalletInitializerService } from "@src/billing/services/wallet-initializer/wallet-initializer.service";
import { GetWalletOptions, WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service";

@scoped(Lifecycle.ResolutionScoped)
export class WalletController {
constructor(
private readonly walletInitializer: WalletInitializerService,
private readonly trialActivationJobService: TrialActivationJobService,
private readonly signerService: ManagedSignerService,
private readonly refillService: RefillService,
private readonly walletReaderService: WalletReaderService,
Expand All @@ -32,27 +28,6 @@ export class WalletController {
private readonly trialValidationService: TrialValidationService
) {}

/**
* Backward-compat shim for `POST /v1/start-trial`: kept so an older deploy-web can still call it during a staged
* rollout where the API ships first. Trial provisioning now runs server-side off registration/verification, so this
* only ensures the wallet exists and (idempotently) enqueues activation, returning the wallet so an old client's
* cache still fills. New clients don't call it.
*
* `ensureWallet` uses the unscoped repository (it also runs from server-side registration with a trusted userId),
* so ownership is enforced here: the request `userId` must be the caller's own, else another user's wallet data
* would leak and their provisioning could be triggered.
*/
@Protected([{ action: "create", subject: "UserWallet" }])
async create({ data: { userId } }: StartTrialRequestInput): Promise<WalletOutputResponse> {
assert(userId === this.authService.currentUser.id, 403, "Cannot start a trial for another user");
const wallet = await this.walletInitializer.ensureWallet(userId);
await this.trialActivationJobService.schedule(userId);

const publicWallet = this.userWalletRepository.toPublic(wallet);
const denom = this.billingConfigService.get("DEPLOYMENT_GRANT_DENOM");
return { data: { ...publicWallet, denom, topUpMinAmountUsd: this.trialValidationService.getTopUpMinAmountUsd(publicWallet) } };
}

@Protected([{ action: "read", subject: "UserWallet" }])
async getWallets(query: GetWalletQuery): Promise<WalletListOutputResponse> {
const denom = this.billingConfigService.get("DEPLOYMENT_GRANT_DENOM");
Expand Down
29 changes: 1 addition & 28 deletions apps/api/src/billing/http-schemas/wallet.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,33 +19,8 @@ const WalletOutputSchema = z.object({
createdAt: z.coerce.date().nullable().openapi({})
});

const WalletWithOptional3DSSchema = WalletOutputSchema.extend({
requires3DS: z.boolean().optional(),
clientSecret: z.string().nullable().optional(),
paymentIntentId: z.string().nullable().optional(),
paymentMethodId: z.string().nullable().optional()
});

export const WalletResponseOutputSchema = z.object({
data: WalletWithOptional3DSSchema
});

export const WalletResponseNo3DSOutputSchema = z.object({
data: WalletWithOptional3DSSchema.strict()
.refine(data => !data.requires3DS, { message: "requires3DS must be false or undefined for 200 responses" })
.refine(data => !data.clientSecret, { message: "clientSecret must be null or undefined for 200 responses" })
.refine(data => !data.paymentIntentId, { message: "paymentIntentId must be null or undefined for 200 responses" })
.refine(data => !data.paymentMethodId, { message: "paymentMethodId must be null or undefined for 200 responses" })
});

export const WalletListResponseOutputSchema = z.object({
data: z.array(WalletWithOptional3DSSchema)
});

export const StartTrialRequestInputSchema = z.object({
data: z.object({
userId: z.string().openapi({})
})
data: z.array(WalletOutputSchema)
});

export const WalletSettingsOutputSchema = z.object({
Expand Down Expand Up @@ -88,8 +63,6 @@ export const UpdateWalletSettingsRequestSchema = z.object({
data: WalletSettingsInputSchema
});

export type StartTrialRequestInput = z.infer<typeof StartTrialRequestInputSchema>;
export type WalletOutputResponse = z.infer<typeof WalletResponseOutputSchema>;
export type WalletListOutputResponse = z.infer<typeof WalletListResponseOutputSchema>;
export type WalletSettingsResponse = z.infer<typeof WalletSettingsResponseSchema>;
export type CreateWalletSettingsRequest = z.infer<typeof CreateWalletSettingsRequestSchema>;
Expand Down
1 change: 0 additions & 1 deletion apps/api/src/billing/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,5 @@ export * from "@src/billing/routes/stripe-customers/stripe-customers.router";
export * from "@src/billing/routes/stripe-transactions/stripe-transactions.router";
export * from "@src/billing/routes/stripe-payment-methods/stripe-payment-methods.router";
export * from "@src/billing/routes/get-balances/get-balances.router";
export * from "@src/billing/routes/start-trial/start-trial.router";
export * from "@src/billing/routes/usage/usage.router";
export * from "@src/billing/routes/wallet-settings/wallet-settings.router";
43 changes: 0 additions & 43 deletions apps/api/src/billing/routes/start-trial/start-trial.router.ts

This file was deleted.

2 changes: 0 additions & 2 deletions apps/api/src/routers/open-api-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
getBalancesRouter,
getWalletListRouter,
signAndBroadcastTxRouter,
startTrialRouter,
stripeCouponsRouter,
stripeCustomersRouter,
stripePaymentMethodsRouter,
Expand Down Expand Up @@ -55,7 +54,6 @@ import { getCurrentUserRouter, registerUserRouter, userSettingsRouter, userTempl
import { validatorsRouter } from "@src/validator";

export const openApiHonoHandlers: OpenApiHonoHandler[] = [
startTrialRouter,
getWalletListRouter,
walletSettingRouter,
signAndBroadcastTxRouter,
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down
Loading
Loading