diff --git a/apps/api/drizzle/0002_opposite_fat_cobra.sql b/apps/api/drizzle/0002_opposite_fat_cobra.sql new file mode 100644 index 000000000..b0c6ca44e --- /dev/null +++ b/apps/api/drizzle/0002_opposite_fat_cobra.sql @@ -0,0 +1 @@ +ALTER TABLE "user_wallets" ADD COLUMN "trial" boolean DEFAULT true; \ No newline at end of file diff --git a/apps/api/drizzle/0003_magenta_mandarin.sql b/apps/api/drizzle/0003_magenta_mandarin.sql new file mode 100644 index 000000000..b615ddc70 --- /dev/null +++ b/apps/api/drizzle/0003_magenta_mandarin.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user_wallets" ADD CONSTRAINT "user_wallets_user_id_unique" UNIQUE("user_id");--> statement-breakpoint +ALTER TABLE "user_wallets" ADD CONSTRAINT "user_wallets_address_unique" UNIQUE("address"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0002_snapshot.json b/apps/api/drizzle/meta/0002_snapshot.json new file mode 100644 index 000000000..1e5c9b576 --- /dev/null +++ b/apps/api/drizzle/meta/0002_snapshot.json @@ -0,0 +1,178 @@ +{ + "id": "b4604536-ec46-489d-978e-dc76f32dbb71", + "prevId": "84417858-3b28-48ef-ae81-3d19629cfd4a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user_wallets": { + "name": "user_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "deployment_allowance": { + "name": "deployment_allowance", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "fee_allowance": { + "name": "fee_allowance", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_wallets_user_id_userSetting_id_fk": { + "name": "user_wallets_user_id_userSetting_id_fk", + "tableFrom": "user_wallets", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.userSetting": { + "name": "userSetting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscribedToNewsletter": { + "name": "subscribedToNewsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "youtubeUsername": { + "name": "youtubeUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "twitterUsername": { + "name": "twitterUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "githubUsername": { + "name": "githubUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "userSetting_userId_unique": { + "name": "userSetting_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "userSetting_username_unique": { + "name": "userSetting_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0003_snapshot.json b/apps/api/drizzle/meta/0003_snapshot.json new file mode 100644 index 000000000..9a85626bd --- /dev/null +++ b/apps/api/drizzle/meta/0003_snapshot.json @@ -0,0 +1,193 @@ +{ + "id": "68facf51-a439-434a-a0c7-1e9c258d57df", + "prevId": "b4604536-ec46-489d-978e-dc76f32dbb71", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user_wallets": { + "name": "user_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "deployment_allowance": { + "name": "deployment_allowance", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "fee_allowance": { + "name": "fee_allowance", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_wallets_user_id_userSetting_id_fk": { + "name": "user_wallets_user_id_userSetting_id_fk", + "tableFrom": "user_wallets", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_wallets_user_id_unique": { + "name": "user_wallets_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "user_wallets_address_unique": { + "name": "user_wallets_address_unique", + "nullsNotDistinct": false, + "columns": [ + "address" + ] + } + } + }, + "public.userSetting": { + "name": "userSetting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscribedToNewsletter": { + "name": "subscribedToNewsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "youtubeUsername": { + "name": "youtubeUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "twitterUsername": { + "name": "twitterUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "githubUsername": { + "name": "githubUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "userSetting_userId_unique": { + "name": "userSetting_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "userSetting_username_unique": { + "name": "userSetting_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index b031fc9e7..e7d03331a 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -15,6 +15,20 @@ "when": 1722870150411, "tag": "0001_absurd_pretty_boy", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1722945530909, + "tag": "0002_opposite_fat_cobra", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1722946059197, + "tag": "0003_magenta_mandarin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/billing/controllers/wallet/wallet.controller.ts b/apps/api/src/billing/controllers/wallet/wallet.controller.ts index c413daef5..976e321a8 100644 --- a/apps/api/src/billing/controllers/wallet/wallet.controller.ts +++ b/apps/api/src/billing/controllers/wallet/wallet.controller.ts @@ -1,5 +1,4 @@ import type { EncodeObject } from "@cosmjs/proto-signing"; -import pick from "lodash/pick"; import { Lifecycle, scoped } from "tsyringe"; import { AuthService, Protected } from "@src/auth/services/auth.service"; @@ -33,7 +32,7 @@ export class WalletController { const wallets = await this.userWalletRepository.accessibleBy(this.authService.ability, "read").find(query); return { - data: wallets.map(wallet => pick(wallet, ["id", "userId", "address", "creditAmount"])) + data: wallets.map(wallet => this.userWalletRepository.toPublic(wallet)) }; } diff --git a/apps/api/src/billing/http-schemas/wallet.schema.ts b/apps/api/src/billing/http-schemas/wallet.schema.ts index 9f7fb400e..80bca2e4c 100644 --- a/apps/api/src/billing/http-schemas/wallet.schema.ts +++ b/apps/api/src/billing/http-schemas/wallet.schema.ts @@ -4,7 +4,8 @@ const WalletOutputSchema = z.object({ id: z.number().openapi({}), userId: z.string().openapi({}), creditAmount: z.number().openapi({}), - address: z.string().openapi({}) + address: z.string().openapi({}), + isTrialing: z.boolean() }); export const WalletResponseOutputSchema = z.object({ diff --git a/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts b/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts index 4269e5ab2..fd1fb264c 100644 --- a/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts +++ b/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts @@ -1,14 +1,17 @@ -import { numeric, pgTable, serial, uuid, varchar } from "drizzle-orm/pg-core"; +import { boolean, numeric, pgTable, serial, uuid, varchar } from "drizzle-orm/pg-core"; import { userSchema } from "@src/user/model-schemas"; export const userWalletSchema = pgTable("user_wallets", { id: serial("id").primaryKey(), - userId: uuid("user_id").references(() => userSchema.id), - address: varchar("address"), + userId: uuid("user_id") + .references(() => userSchema.id) + .unique(), + address: varchar("address").unique(), stripeCustomerId: varchar("stripe_customer_id"), deploymentAllowance: allowance("deployment_allowance"), - feeAllowance: allowance("fee_allowance") + feeAllowance: allowance("fee_allowance"), + isTrialing: boolean("trial").default(true) }); function allowance(name: string) { 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 5b4827751..260ecc0cf 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 @@ -1,5 +1,6 @@ import { and, eq, lte, or } from "drizzle-orm"; import first from "lodash/first"; +import pick from "lodash/pick"; import { singleton } from "tsyringe"; import { InjectUserWalletSchema, UserWalletSchema } from "@src/billing/providers"; @@ -99,4 +100,8 @@ export class UserWalletRepository extends BaseRepository { } ); } + + toPublic(output: T): Pick { + return pick(output, ["id", "userId", "address", "creditAmount", "isTrialing"]); + } } diff --git a/apps/api/src/billing/services/balances/balances.service.ts b/apps/api/src/billing/services/balances/balances.service.ts index a6d166329..93a3dc12f 100644 --- a/apps/api/src/billing/services/balances/balances.service.ts +++ b/apps/api/src/billing/services/balances/balances.service.ts @@ -14,7 +14,15 @@ export class BalancesService { private readonly allowanceHttpService: AllowanceHttpService ) {} - async updateUserWalletLimits(userWallet: UserWalletOutput) { + async updateUserWalletLimits(userWallet: UserWalletOutput): Promise { + const update = await this.getLimitsUpdate(userWallet); + + if (Object.keys(update).length > 0) { + await this.userWalletRepository.updateById(userWallet.id, update); + } + } + + async getLimitsUpdate(userWallet: UserWalletOutput): Promise> { const [feeLimit, deploymentLimit] = await Promise.all([this.calculateFeeLimit(userWallet), this.calculateDeploymentLimit(userWallet)]); const update: Partial = {}; @@ -31,12 +39,10 @@ export class BalancesService { update.deploymentAllowance = deploymentLimitStr; } - if (Object.keys(update).length > 0) { - await this.userWalletRepository.updateById(userWallet.id, update); - } + return update; } - private async calculateFeeLimit(userWallet: UserWalletOutput) { + private async calculateFeeLimit(userWallet: UserWalletOutput): Promise { const feeAllowance = await this.allowanceHttpService.getFeeAllowancesForGrantee(userWallet.address); const masterWalletAddress = await this.masterWalletService.getFirstAddress(); @@ -55,7 +61,7 @@ export class BalancesService { }, 0); } - private async calculateDeploymentLimit(userWallet: UserWalletOutput) { + private async calculateDeploymentLimit(userWallet: UserWalletOutput): Promise { const deploymentAllowance = await this.allowanceHttpService.getDeploymentAllowancesForGrantee(userWallet.address); const masterWalletAddress = await this.masterWalletService.getFirstAddress(); diff --git a/apps/api/src/billing/services/refill/refill.service.ts b/apps/api/src/billing/services/refill/refill.service.ts index d8e4de61d..491f13f81 100644 --- a/apps/api/src/billing/services/refill/refill.service.ts +++ b/apps/api/src/billing/services/refill/refill.service.ts @@ -48,7 +48,12 @@ export class RefillService { limits }); - await this.balancesService.updateUserWalletLimits(wallet); + const limitsUpdate = await this.balancesService.getLimitsUpdate(wallet); + + if (Object.keys(limitsUpdate).length > 0) { + limitsUpdate.isTrialing = false; + await this.userWalletRepository.updateById(wallet.id, limitsUpdate); + } } private async chargeUser(wallet: UserWalletOutput) { 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 7e96a658d..e0c2ebac2 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 @@ -1,4 +1,3 @@ -import pick from "lodash/pick"; import { singleton } from "tsyringe"; import { AuthService } from "@src/auth/services/auth.service"; @@ -28,6 +27,6 @@ export class WalletInitializerService { { returning: true } ); - return pick(userWallet, ["id", "userId", "address", "creditAmount"]); + return this.userWalletRepository.toPublic(userWallet); } } diff --git a/apps/api/test/functional/create-wallet.spec.ts b/apps/api/test/functional/create-wallet.spec.ts index 3d000792b..f83a81c2b 100644 --- a/apps/api/test/functional/create-wallet.spec.ts +++ b/apps/api/test/functional/create-wallet.spec.ts @@ -45,7 +45,8 @@ describe("wallets", () => { id: expect.any(Number), userId, creditAmount: expect.any(Number), - address: expect.any(String) + address: expect.any(String), + isTrialing: true } }); expect(await getWalletsResponse.json()).toMatchObject({ @@ -54,7 +55,8 @@ describe("wallets", () => { id: expect.any(Number), userId, creditAmount: expect.any(Number), - address: expect.any(String) + address: expect.any(String), + isTrialing: true } ] }); @@ -63,7 +65,8 @@ describe("wallets", () => { userId, address: expect.any(String), deploymentAllowance: `${config.TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT}.00`, - feeAllowance: `${config.TRIAL_FEES_ALLOWANCE_AMOUNT}.00` + feeAllowance: `${config.TRIAL_FEES_ALLOWANCE_AMOUNT}.00`, + isTrialing: true }); }); diff --git a/apps/api/test/functional/wallets-refill.spec.ts b/apps/api/test/functional/wallets-refill.spec.ts index 49e7a643d..5dda193e2 100644 --- a/apps/api/test/functional/wallets-refill.spec.ts +++ b/apps/api/test/functional/wallets-refill.spec.ts @@ -52,6 +52,7 @@ describe("Wallets Refill", () => { wallet = await walletService.getWalletByUserId(user.id); expect(wallet.creditAmount).toBe(config.DEPLOYMENT_ALLOWANCE_REFILL_THRESHOLD + config.FEE_ALLOWANCE_REFILL_THRESHOLD); + expect(wallet.isTrialing).toBe(true); return { user, wallet }; }); @@ -63,6 +64,7 @@ describe("Wallets Refill", () => { records.map(async ({ wallet, user }) => { wallet = await walletService.getWalletByUserId(user.id); expect(wallet.creditAmount).toBe(config.DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT + config.FEE_ALLOWANCE_REFILL_AMOUNT); + expect(wallet.isTrialing).toBe(false); }) ); }); diff --git a/apps/deploy-web/src/components/layout/WalletStatus.tsx b/apps/deploy-web/src/components/layout/WalletStatus.tsx index 5646521f7..4c1174cdf 100644 --- a/apps/deploy-web/src/components/layout/WalletStatus.tsx +++ b/apps/deploy-web/src/components/layout/WalletStatus.tsx @@ -27,7 +27,7 @@ import { FormattedDecimal } from "../shared/FormattedDecimal"; import { ConnectWalletButton } from "../wallet/ConnectWalletButton"; export function WalletStatus() { - const { walletName, address, walletBalances, logout, isWalletLoaded, isWalletConnected, isManaged, isWalletLoading } = useWallet(); + const { walletName, address, walletBalances, logout, isWalletLoaded, isWalletConnected, isManaged, isWalletLoading, isTrialing } = useWallet(); const walletBalance = useTotalWalletBalance(); const router = useRouter(); function onDisconnectClick() { @@ -70,6 +70,7 @@ export function WalletStatus() {
+ {isManaged && isTrialing &&

Trial

} {!isManaged && ( diff --git a/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx b/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx index c10356825..64220b875 100644 --- a/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx +++ b/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx @@ -56,6 +56,7 @@ type ContextType = { refreshBalances: (address?: string) => Promise; isManaged: boolean; isWalletLoading: boolean; + isTrialing: boolean; }; const WalletProviderContext = React.createContext({} as ContextType); @@ -338,7 +339,8 @@ export const WalletProvider = ({ children }) => { signAndBroadcastTx, refreshBalances, isManaged: !!managedWallet?.isWalletConnected, - isWalletLoading: isLoading + isWalletLoading: isLoading, + isTrialing: !!managedWallet?.isTrialing }} > {children} diff --git a/packages/http-sdk/src/managed-wallet-http/managed-wallet-http.service.ts b/packages/http-sdk/src/managed-wallet-http/managed-wallet-http.service.ts index 16d45ba3f..b70dffd88 100644 --- a/packages/http-sdk/src/managed-wallet-http/managed-wallet-http.service.ts +++ b/packages/http-sdk/src/managed-wallet-http/managed-wallet-http.service.ts @@ -5,6 +5,7 @@ export interface ApiWalletOutput { userId: string; address: string; creditAmount: number; + isTrialing: boolean; } export class ManagedWalletHttpService extends ApiHttpService {