Skip to content

Commit

Permalink
feat(billing): implement managed wallet top up 1
Browse files Browse the repository at this point in the history
refs #247
  • Loading branch information
ygrishajev committed Aug 27, 2024
1 parent 04f5aad commit bd4c06b
Show file tree
Hide file tree
Showing 17 changed files with 89 additions and 53 deletions.
3 changes: 2 additions & 1 deletion apps/api/env/.env.functional.test
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ ANONYMOUS_USER_TOKEN_SECRET=ANONYMOUS_USER_TOKEN_SECRET
STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
STRIPE_PRICE_ID=STRIPE_PRICE_ID
STRIPE_WEBHOOK_SECRET=STRIPE_WEBHOOK_SECRET
ALLOWED_CHECKOUT_REFERRERS=["http://localhost:3000"]
ALLOWED_CHECKOUT_REFERRERS=["http://localhost:3000"]
STRIPE_CHECKOUT_REDIRECT_URL=http://localhost:3000
2 changes: 2 additions & 0 deletions apps/api/src/billing/controllers/wallet/wallet.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { WalletInitializerService } from "@src/billing/services";
import { RefillService } from "@src/billing/services/refill/refill.service";
import { TxSignerService } from "@src/billing/services/tx-signer/tx-signer.service";
import { GetWalletOptions, WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service";
import { WithTransaction } from "@src/core";

@scoped(Lifecycle.ResolutionScoped)
export class WalletController {
Expand All @@ -19,6 +20,7 @@ export class WalletController {
private readonly walletReaderService: WalletReaderService
) {}

@WithTransaction()
@Protected([{ action: "create", subject: "UserWallet" }])
async create({ data: { userId } }: CreateWalletRequestInput): Promise<WalletOutputResponse> {
return {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { boolean, integer, numeric, pgTable, serial, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
import { boolean, numeric, pgTable, serial, timestamp, uuid, varchar } from "drizzle-orm/pg-core";

import { Users } from "@src/user/model-schemas";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,16 @@ export class UserWalletRepository extends BaseRepository<ApiPgTables["UserWallet
}

protected toOutput(dbOutput: DbUserWalletOutput): UserWalletOutput {
const deploymentAllowance = parseFloat(dbOutput.deploymentAllowance);
return {
...omit(dbOutput, ["feeAllowance", "deploymentAllowance"]),
creditAmount: deploymentAllowance,
deploymentAllowance,
feeAllowance: parseFloat(dbOutput.feeAllowance)
};
const deploymentAllowance = dbOutput?.deploymentAllowance && parseFloat(dbOutput.deploymentAllowance);

return (
dbOutput && {
...omit(dbOutput, ["feeAllowance", "deploymentAllowance"]),
creditAmount: deploymentAllowance,
deploymentAllowance,
feeAllowance: parseFloat(dbOutput.feeAllowance)
}
);
}

protected toInput({ deploymentAllowance, feeAllowance, ...input }: UserWalletInput): DbUserWalletInput {
Expand Down
9 changes: 7 additions & 2 deletions apps/api/src/billing/services/refill/refill.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { UserWalletOutput, UserWalletRepository } from "@src/billing/repositorie
import { ManagedUserWalletService, WalletInitializerService } from "@src/billing/services";
import { BalancesService } from "@src/billing/services/balances/balances.service";
import { LoggerService } from "@src/core";
import { InjectSentry, Sentry } from "@src/core/providers/sentry.provider";
import { SentryEventService } from "@src/core/services/sentry-event/sentry-event.service";

@singleton()
export class RefillService {
Expand All @@ -16,7 +18,9 @@ export class RefillService {
private readonly userWalletRepository: UserWalletRepository,
private readonly managedUserWalletService: ManagedUserWalletService,
private readonly balancesService: BalancesService,
private readonly walletInitializerService: WalletInitializerService
private readonly walletInitializerService: WalletInitializerService,
@InjectSentry() private readonly sentry: Sentry,
private readonly sentryEventService: SentryEventService
) {}

async refillAllFees() {
Expand All @@ -30,7 +34,8 @@ export class RefillService {
.process(async wallet => this.refillWalletFees(wallet));

if (errors.length) {
this.logger.error({ event: "WALLETS_REFILL_ERROR", errors });
const id = this.sentry.captureEvent(this.sentryEventService.toEvent(errors));
this.logger.error({ event: "WALLETS_REFILL_ERROR", errors, sentryEventId: id });
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import { singleton } from "tsyringe";
import { CheckoutSessionRepository } from "@src/billing/repositories";
import { RefillService } from "@src/billing/services/refill/refill.service";
import { StripeService } from "@src/billing/services/stripe/stripe.service";
import { LoggerService, WithTransaction } from "@src/core";

@singleton()
export class StripeWebhookService {
private readonly logger = new LoggerService({ context: StripeWebhookService.name });

constructor(
private readonly stripe: StripeService,
private readonly checkoutSessionRepository: CheckoutSessionRepository,
Expand All @@ -15,16 +18,20 @@ export class StripeWebhookService {

async routeStripeEvent(signature: string, rawEvent: string) {
const event = this.stripe.webhooks.constructEvent(rawEvent, signature, process.env.STRIPE_WEBHOOK_SECRET);
this.logger.info({ event: "STRIPE_EVENT_RECEIVED", type: event.type });

if (event.type === "checkout.session.completed" || event.type === "checkout.session.async_payment_succeeded") {
await this.tryToTopUpWallet(event);
}
}

@WithTransaction()
async tryToTopUpWallet(event: Stripe.CheckoutSessionCompletedEvent | Stripe.CheckoutSessionAsyncPaymentSucceededEvent) {
const checkoutSessionCache = await this.checkoutSessionRepository.findOneBy({ sessionId: event.data.object.id });
const sessionId = event.data.object.id;
const checkoutSessionCache = await this.checkoutSessionRepository.findOneByAndLock({ sessionId });

if (!checkoutSessionCache) {
this.logger.info({ event: "SESSION_NOT_FOUND", sessionId });
return;
}

Expand All @@ -35,6 +42,8 @@ export class StripeWebhookService {
if (checkoutSession.payment_status !== "unpaid") {
await this.refillService.topUpWallet(checkoutSession.amount_total, checkoutSessionCache.userId);
await this.checkoutSessionRepository.deleteBy({ sessionId: event.data.object.id });
} else {
this.logger.error({ event: "PAYMENT_NOT_COMPLETED", sessionId });
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { singleton } from "tsyringe";
import { AuthService } from "@src/auth/services/auth.service";
import { UserWalletInput, UserWalletRepository } from "@src/billing/repositories";
import { ManagedUserWalletService } from "@src/billing/services";
import { WithTransaction } from "@src/core/services";

@singleton()
export class WalletInitializerService {
Expand All @@ -13,7 +12,6 @@ export class WalletInitializerService {
private readonly authService: AuthService
) {}

@WithTransaction()
async initializeAndGrantTrialLimits(userId: UserWalletInput["userId"]) {
const { id } = await this.userWalletRepository.accessibleBy(this.authService.ability, "create").create({ userId });
const wallet = await this.walletManager.createAndAuthorizeTrialSpending({ addressIndex: id });
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/core/repositories/base.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ export abstract class BaseRepository<
);
}

async findOneByAndLock(query?: Partial<Output>) {
const items: T["$inferSelect"][] = await this.txManager.getPgTx()?.select().from(this.table).where(this.queryToWhere(query)).limit(1).for("update");
return this.toOutput(first(items));
}

async find(query?: Partial<Output>) {
return this.toOutputList(
await this.queryCursor.findMany({
Expand Down
7 changes: 3 additions & 4 deletions apps/api/test/functional/anonymous-user.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { DbTestingService } from "@test/services/db-testing.service";
import { container } from "tsyringe";

import { app } from "@src/app";
import { ApiPgDatabase, POSTGRES_DB, resolveTable } from "@src/core";
import { AnonymousUserResponseOutput } from "@src/user/schemas/user.schema";

describe("Users", () => {
const UsersTable = resolveTable("Users");
const db = container.resolve<ApiPgDatabase>(POSTGRES_DB);
let user: AnonymousUserResponseOutput["data"];
let token: AnonymousUserResponseOutput["token"];
const dbService = container.resolve(DbTestingService);

beforeEach(async () => {
const userResponse = await app.request("/v1/anonymous-users", {
Expand All @@ -21,7 +20,7 @@ describe("Users", () => {
});

afterEach(async () => {
await db.delete(UsersTable);
await dbService.cleanAll();
});

describe("POST /v1/anonymous-users", () => {
Expand Down
12 changes: 5 additions & 7 deletions apps/api/test/functional/create-deployment.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { certificateManager } from "@akashnetwork/akashjs/build/certificates/certificate-manager";
import { SDL } from "@akashnetwork/akashjs/build/sdl";
import type { Registry } from "@cosmjs/proto-signing";
import { WalletService } from "@test/services/wallet.service";
import { DbTestingService } from "@test/services/db-testing.service";
import { WalletTestingService } from "@test/services/wallet-testing.service";
import axios from "axios";
import * as fs from "node:fs";
import * as path from "node:path";
Expand All @@ -11,7 +12,6 @@ import { app } from "@src/app";
import { config } from "@src/billing/config";
import { TYPE_REGISTRY } from "@src/billing/providers/type-registry.provider";
import { MasterWalletService } from "@src/billing/services";
import { ApiPgDatabase, POSTGRES_DB, resolveTable } from "@src/core";

jest.setTimeout(30000);

Expand All @@ -20,14 +20,12 @@ const yml = fs.readFileSync(path.resolve(__dirname, "../mocks/hello-world-sdl.ym
// TODO: finish this test to create a lease and then close the deployment
describe("Tx Sign", () => {
const registry = container.resolve<Registry>(TYPE_REGISTRY);
const db = container.resolve<ApiPgDatabase>(POSTGRES_DB);
const userWalletsTable = resolveTable("UserWallets");
const usersTable = resolveTable("Users");
const walletService = new WalletService(app);
const walletService = new WalletTestingService(app);
const masterWalletService = container.resolve(MasterWalletService);
const dbService = container.resolve(DbTestingService);

afterEach(async () => {
await Promise.all([db.delete(userWalletsTable), db.delete(usersTable)]);
await dbService.cleanAll();
});

describe("POST /v1/tx", () => {
Expand Down
9 changes: 5 additions & 4 deletions apps/api/test/functional/create-wallet.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AllowanceHttpService } from "@akashnetwork/http-sdk";
import { faker } from "@faker-js/faker";
import { DbTestingService } from "@test/services/db-testing.service";
import { eq } from "drizzle-orm";
import { container } from "tsyringe";

Expand All @@ -11,14 +12,14 @@ jest.setTimeout(20000);

describe("wallets", () => {
const userWalletsTable = resolveTable("UserWallets");
const userTable = resolveTable("Users");
const config = container.resolve<BillingConfig>(BILLING_CONFIG);
const db = container.resolve<ApiPgDatabase>(POSTGRES_DB);
const userWalletsQuery = db.query.UserWallets;
const allowanceHttpService = container.resolve(AllowanceHttpService);
const dbService = container.resolve(DbTestingService);

afterEach(async () => {
await Promise.all([db.delete(userWalletsTable), db.delete(userTable)]);
await dbService.cleanAll();
});

describe("POST /v1/wallets", () => {
Expand Down Expand Up @@ -70,8 +71,8 @@ describe("wallets", () => {
id: expect.any(Number),
userId,
address: expect.any(String),
deploymentAllowance: config.TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT,
feeAllowance: config.TRIAL_FEES_ALLOWANCE_AMOUNT,
deploymentAllowance: `${config.TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT}.00`,
feeAllowance: `${config.TRIAL_FEES_ALLOWANCE_AMOUNT}.00`,
isTrialing: true
});
expect(allowances).toMatchObject([
Expand Down
12 changes: 5 additions & 7 deletions apps/api/test/functional/sign-and-broadcast-tx.spec.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
import { certificateManager } from "@akashnetwork/akashjs/build/certificates/certificate-manager";
import type { Registry } from "@cosmjs/proto-signing";
import { WalletService } from "@test/services/wallet.service";
import { DbTestingService } from "@test/services/db-testing.service";
import { WalletTestingService } from "@test/services/wallet-testing.service";
import { container } from "tsyringe";

import { app } from "@src/app";
import { TYPE_REGISTRY } from "@src/billing/providers/type-registry.provider";
import { ApiPgDatabase, POSTGRES_DB, resolveTable } from "@src/core";

jest.setTimeout(30000);

describe("Tx Sign", () => {
const registry = container.resolve<Registry>(TYPE_REGISTRY);
const db = container.resolve<ApiPgDatabase>(POSTGRES_DB);
const userWalletsTable = resolveTable("UserWallets");
const usersTable = resolveTable("Users");
const walletService = new WalletService(app);
const walletService = new WalletTestingService(app);
const dbService = container.resolve(DbTestingService);

afterEach(async () => {
await Promise.all([db.delete(userWalletsTable), db.delete(usersTable)]);
await dbService.cleanAll();
});

describe("POST /v1/tx", () => {
Expand Down
10 changes: 5 additions & 5 deletions apps/api/test/functional/user-init.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { faker } from "@faker-js/faker";
import { WalletService } from "@test/services/wallet.service";
import { DbTestingService } from "@test/services/db-testing.service";
import { WalletTestingService } from "@test/services/wallet-testing.service";
import type { Context, Next } from "hono";
import first from "lodash/first";
import omit from "lodash/omit";
Expand All @@ -20,10 +21,9 @@ jest.setTimeout(30000);

describe("User Init", () => {
const usersTable = resolveTable("Users");
const userWalletsTable = resolveTable("UserWallets");
const userWalletRepository = container.resolve(UserWalletRepository);
const db = container.resolve<ApiPgDatabase>(POSTGRES_DB);
const walletService = new WalletService(app);
const walletService = new WalletTestingService(app);
let auth0Payload: {
userId: string;
wantedUsername: string;
Expand Down Expand Up @@ -57,10 +57,10 @@ describe("User Init", () => {

(getCurrentUserId as jest.Mock).mockReturnValue(auth0Payload.userId);
});
const dbService = container.resolve(DbTestingService);

afterEach(async () => {
await db.delete(userWalletsTable);
await db.delete(usersTable);
await dbService.cleanAll();
});

describe("POST /user/tokenInfo", () => {
Expand Down
12 changes: 5 additions & 7 deletions apps/api/test/functional/wallets-refill.spec.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,25 @@
import { WalletService } from "@test/services/wallet.service";
import { DbTestingService } from "@test/services/db-testing.service";
import { WalletTestingService } from "@test/services/wallet-testing.service";
import { container } from "tsyringe";

import { app } from "@src/app";
import { WalletController } from "@src/billing/controllers/wallet/wallet.controller";
import { BILLING_CONFIG, BillingConfig } from "@src/billing/providers";
import { UserWalletRepository } from "@src/billing/repositories";
import { ManagedUserWalletService } from "@src/billing/services";
import { ApiPgDatabase, POSTGRES_DB, resolveTable } from "@src/core";

jest.setTimeout(240000);

describe("Wallets Refill", () => {
const managedUserWalletService = container.resolve(ManagedUserWalletService);
const db = container.resolve<ApiPgDatabase>(POSTGRES_DB);
const userWalletsTable = resolveTable("UserWallets");
const usersTable = resolveTable("Users");
const config = container.resolve<BillingConfig>(BILLING_CONFIG);
const walletController = container.resolve(WalletController);
const walletService = new WalletService(app);
const walletService = new WalletTestingService(app);
const userWalletRepository = container.resolve(UserWalletRepository);
const dbService = container.resolve(DbTestingService);

afterEach(async () => {
await Promise.all([db.delete(userWalletsTable), db.delete(usersTable)]);
await dbService.cleanAll();
});

describe("console refill-wallets", () => {
Expand Down
19 changes: 19 additions & 0 deletions apps/api/test/services/db-testing.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { singleton } from "tsyringe";

import { ApiPgDatabase, ApiPgTables, InjectPg, InjectPgTable } from "@src/core";

@singleton()
export class DbTestingService {
constructor(
@InjectPg() private readonly pg: ApiPgDatabase,
@InjectPgTable("Users") private readonly users: ApiPgTables["Users"],
@InjectPgTable("UserWallets") private readonly userWallets: ApiPgTables["UserWallets"],
@InjectPgTable("CheckoutSessions") private readonly checkoutSessions: ApiPgTables["CheckoutSessions"]
) {}

async cleanAll() {
await this.pg.delete(this.checkoutSessions);
await this.pg.delete(this.userWallets);
await this.pg.delete(this.users);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Hono } from "hono";

export class WalletService {
export class WalletTestingService {
constructor(private readonly app: Hono) {}

async createUserAndWallet() {
Expand Down
Loading

0 comments on commit bd4c06b

Please sign in to comment.