Skip to content

Commit

Permalink
feat(billing): enable checkout options with promo codes
Browse files Browse the repository at this point in the history
  • Loading branch information
ygrishajev committed Nov 21, 2024
1 parent 1242954 commit 73ccf7c
Show file tree
Hide file tree
Showing 25 changed files with 2,093 additions and 2,525 deletions.
1 change: 1 addition & 0 deletions apps/api/env/.env
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ TRIAL_FEES_ALLOWANCE_AMOUNT=1000000
FEE_ALLOWANCE_REFILL_AMOUNT=1000000
FEE_ALLOWANCE_REFILL_THRESHOLD=100000
LOG_LEVEL=debug
STRIPE_PRODUCT_ID=prod_QjTVQg5WkIe39Q
2 changes: 1 addition & 1 deletion apps/api/env/.env.functional.test
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ LOG_LEVEL=debug
BILLING_ENABLED=true
ANONYMOUS_USER_TOKEN_SECRET=ANONYMOUS_USER_TOKEN_SECRET
STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
STRIPE_PRICE_ID=STRIPE_PRICE_ID
STRIPE_PRODUCT_ID=STRIPE_PRODUCT_ID
STRIPE_WEBHOOK_SECRET=STRIPE_WEBHOOK_SECRET
ALLOWED_CHECKOUT_REFERRERS=["http://localhost:3000"]
STRIPE_CHECKOUT_REDIRECT_URL=http://localhost:3000
2 changes: 1 addition & 1 deletion apps/api/env/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ POSTGRES_DB_URI=
SECRET_TOKEN=
SENTRY_DSN=
STRIPE_SECRET_KEY=
STRIPE_PRICE_ID=
STRIPE_PRODUCT_ID=
USER_DATABASE_CS=

# Configuration
Expand Down
1 change: 0 additions & 1 deletion apps/api/env/.env.staging
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,4 @@ AUTH0_ISSUER=https://dev-5aprb0lr.us.auth0.com/
SENTRY_TRACES_RATE=1.0
SENTRY_ENABLED=true
BILLING_ENABLED=true
STRIPE_PRICE_ID=price_1Ps0Y0Csz6Fy2xVWVy8GMTA9
STRIPE_CHECKOUT_REDIRECT_URL=https://console-beta.akash.network
2 changes: 1 addition & 1 deletion apps/api/env/.env.unit.test
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ LOG_LEVEL=debug
BILLING_ENABLED=true
ANONYMOUS_USER_TOKEN_SECRET=ANONYMOUS_USER_TOKEN_SECRET
STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
STRIPE_PRICE_ID=STRIPE_PRICE_ID
STRIPE_PRODUCT_ID=STRIPE_PRODUCT_ID
STRIPE_WEBHOOK_SECRET=STRIPE_WEBHOOK_SECRET
ALLOWED_CHECKOUT_REFERRERS=["http://localhost:3000"]
STRIPE_CHECKOUT_REDIRECT_URL=http://localhost:3000
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { userRouter } from "./routers/userRouter";
import { web3IndexRouter } from "./routers/web3indexRouter";
import { env } from "./utils/env";
import { bytesToHumanReadableSize } from "./utils/files";
import { checkoutRouter, getWalletListRouter, signAndBroadcastTxRouter, startTrialRouter, stripeWebhook } from "./billing";
import { checkoutRouter, getWalletListRouter, signAndBroadcastTxRouter, startTrialRouter, stripePricesRouter, stripeWebhook } from "./billing";
import { Scheduler } from "./scheduler";
import { createAnonymousUserRouter, getAnonymousUserRouter } from "./user";

Expand Down Expand Up @@ -72,6 +72,7 @@ appHono.route("/", getWalletListRouter);
appHono.route("/", signAndBroadcastTxRouter);
appHono.route("/", checkoutRouter);
appHono.route("/", stripeWebhook);
appHono.route("/", stripePricesRouter);

appHono.route("/", createAnonymousUserRouter);
appHono.route("/", getAnonymousUserRouter);
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/billing/config/env.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const envSchema = z.object({
ALLOWANCE_REFILL_BATCH_SIZE: z.number({ coerce: true }).default(10),
MASTER_WALLET_BATCHING_INTERVAL_MS: z.number().optional().default(1000),
STRIPE_SECRET_KEY: z.string(),
STRIPE_PRICE_ID: z.string(),
STRIPE_PRODUCT_ID: z.string(),
STRIPE_WEBHOOK_SECRET: z.string(),
STRIPE_CHECKOUT_REDIRECT_URL: z.string(),
STRIPE_ENABLE_COUPONS: z.enum(["true", "false"]).default("false")
Expand Down
15 changes: 13 additions & 2 deletions apps/api/src/billing/controllers/checkout/checkout.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,20 @@ export class CheckoutController {
return c.redirect(`${redirectUrl}?unauthorized=true`);
}

const session = await this.checkoutService.checkoutFor(currentUser, redirectUrl);
try {
const session = await this.checkoutService.checkoutFor({
user: currentUser,
redirectUrl,
amount: c.req.query("amount")
});

return c.redirect(session.url);
return c.redirect(session.url);
} catch (error) {
if (error.message === "Price invalid") {
return c.redirect(`${redirectUrl}?invalid-price=true`);
}
return c.redirect(`${redirectUrl}?unknown-error=true`);
}
}

async webhook(signature: string, input: string) {
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/billing/controllers/stripe/stripe.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { singleton } from "tsyringe";

import { StripePricesOutputResponse } from "@src/billing";
import { StripeService } from "@src/billing/services/stripe/stripe.service";

@singleton()
export class StripeController {
constructor(private readonly stripe: StripeService) {}

async findPrices(): Promise<StripePricesOutputResponse> {
const { data: prices } = await this.stripe.prices.list();
return {
data: prices
.map(price => ({
unitAmount: price.custom_unit_amount ? undefined : price.unit_amount / 100,
isCustom: !!price.custom_unit_amount,
currency: price.currency
}))
.sort((a, b) => (a.isCustom === b.isCustom ? a.unitAmount! - b.unitAmount! : a.isCustom ? 1 : -1))
};
}
}
7 changes: 6 additions & 1 deletion apps/api/src/billing/routes/checkout/checkout.router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createRoute } from "@hono/zod-openapi";
import { container } from "tsyringe";
import { z } from "zod";

import { CheckoutController } from "@src/billing/controllers/checkout/checkout.controller";
import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler";
Expand All @@ -9,7 +10,11 @@ const route = createRoute({
path: "/v1/checkout",
summary: "Creates a stripe checkout session and redirects to checkout",
tags: ["Wallet"],
request: {},
request: {
query: z.object({
amount: z.string().optional()
})
},
responses: {
301: {
description: "Redirects to the checkout page"
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/billing/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from "@src/billing/routes/get-wallet-list/get-wallet-list.router";
export * from "@src/billing/routes/checkout/checkout.router";
export * from "@src/billing/routes/sign-and-broadcast-tx/sign-and-broadcast-tx.router";
export * from "@src/billing/routes/stripe-webhook/stripe-webhook.router";
export * from "@src/billing/routes/stripe-prices/stripe-prices.router";
42 changes: 42 additions & 0 deletions apps/api/src/billing/routes/stripe-prices/stripe-prices.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { createRoute } from "@hono/zod-openapi";
import { container } from "tsyringe";
import { z } from "zod";

import { StripeController } from "@src/billing/controllers/stripe/stripe.controller";
import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler";

const StripePricesOutputSchema = z.object({
currency: z.string().openapi({}),
unitAmount: z.number().openapi({}),
isCustom: z.boolean().openapi({})
});

export const StripePricesResponseOutputSchema = z.object({
data: z.array(StripePricesOutputSchema)
});

export type StripePricesOutputResponse = z.infer<typeof StripePricesResponseOutputSchema>;

const route = createRoute({
method: "get",
path: "/v1/stripe-prices",
summary: "",
request: {},
responses: {
200: {
description: "",
content: {
"application/json": {
schema: StripePricesResponseOutputSchema
}
}
}
}
});

export const stripePricesRouter = new OpenApiHonoHandler();

stripePricesRouter.openapi(route, async function routeStripePrices(c) {
await container.resolve(StripeController).findPrices();
return c.json(await container.resolve(StripeController).findPrices(), 200);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { singleton } from "tsyringe";

import { envSchema } from "@src/billing/config/env.config";
import { ConfigService } from "@src/core/services/config/config.service";

@singleton()
export class BillingConfigService extends ConfigService<typeof envSchema, unknown> {
constructor() {
super({ envSchema });
}
}
11 changes: 9 additions & 2 deletions apps/api/src/billing/services/checkout/checkout.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { CheckoutSessionRepository } from "@src/billing/repositories";
import { StripeService } from "@src/billing/services/stripe/stripe.service";
import { UserOutput, UserRepository } from "@src/user/repositories";

interface CheckoutSessionOptions {
user: UserOutput;
redirectUrl: string;
amount?: string;
}

@singleton()
export class CheckoutService {
constructor(
Expand All @@ -12,12 +18,13 @@ export class CheckoutService {
private readonly checkoutSessionRepository: CheckoutSessionRepository
) {}

async checkoutFor(user: UserOutput, redirectUrl: string) {
async checkoutFor({ user, redirectUrl, amount }: CheckoutSessionOptions) {
const { stripeCustomerId } = await this.ensureCustomer(user);

const session = await this.stripe.startCheckoutSession({
customerId: stripeCustomerId,
redirectUrl
redirectUrl,
amount
});

await this.checkoutSessionRepository.create({
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/billing/services/refill/refill.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,6 @@ export class RefillService {
});

await this.balancesService.refreshUserWalletLimits(userWallet, { endTrial: true });
this.logger.debug({ event: "WALLET_TOP_UP", limits });
this.logger.debug({ event: "WALLET_TOP_UP", userWallet, limits });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class StripeWebhookService {
});

if (checkoutSession.payment_status !== "unpaid") {
await this.refillService.topUpWallet(checkoutSession.amount_total, checkoutSessionCache.userId);
await this.refillService.topUpWallet(checkoutSession.amount_subtotal, checkoutSessionCache.userId);
await this.checkoutSessionRepository.deleteBy({ sessionId: event.data.object.id });
} else {
this.logger.error({ event: "PAYMENT_NOT_COMPLETED", sessionId });
Expand Down
30 changes: 26 additions & 4 deletions apps/api/src/billing/services/stripe/stripe.service.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,56 @@
import assert from "http-assert";
import Stripe from "stripe";
import { singleton } from "tsyringe";

import { BillingConfig, InjectBillingConfig } from "@src/billing/providers";
import { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service";

interface CheckoutOptions {
customerId: string;
redirectUrl: string;
amount?: string;
}

@singleton()
export class StripeService extends Stripe {
constructor(@InjectBillingConfig() private readonly billingConfig: BillingConfig) {
constructor(private readonly billingConfig: BillingConfigService) {
super(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2024-06-20"
});
}

async startCheckoutSession(options: CheckoutOptions) {
const price = await this.getPrice(options.amount);

return await this.checkout.sessions.create({
line_items: [
{
price: this.billingConfig.STRIPE_PRICE_ID,
price: price.id,
quantity: 1
}
],
mode: "payment",
allow_promotion_codes: this.billingConfig.STRIPE_ENABLE_COUPONS === "true",
allow_promotion_codes: !!options.amount,
customer: options.customerId,
success_url: `${options.redirectUrl}?session_id={CHECKOUT_SESSION_ID}&payment-success=true`,
cancel_url: `${options.redirectUrl}?session_id={CHECKOUT_SESSION_ID}&payment-canceled=true`
});
}

private async getPrice(amount?: string) {
const { data: prices } = await this.prices.list({ product: this.billingConfig.get("STRIPE_PRODUCT_ID") });

const price = prices.find(price => {
const isCustom = !amount && !!price.custom_unit_amount;

if (isCustom) {
return true;
}

return price.unit_amount === Number(amount) * 100;
});

assert(price, 400, "Price invalid");

return price;
}
}
19 changes: 11 additions & 8 deletions apps/deploy-web/src/components/get-started/GetStartedStepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Check, HandCard, Rocket, WarningCircle, XmarkCircleSolid } from "iconoi
import { useAtom } from "jotai";
import Link from "next/link";

import { TopUpAmountPicker } from "@src/components/top-up-amount-picker/TopUpAmountPicker";
import { LoginRequiredLink } from "@src/components/user/LoginRequiredLink";
import { ConnectManagedWalletButton } from "@src/components/wallet/ConnectManagedWalletButton";
import { browserEnvConfig } from "@src/config/browser-env.config";
Expand Down Expand Up @@ -103,14 +104,16 @@ export const GetStartedStepper: React.FunctionComponent = () => {

<div className="my-4 flex items-center space-x-4">
{isManagedWallet && (
<LoginRequiredLink
className={cn("hover:no-underline", buttonVariants({ variant: "outline", className: "mr-2 border-primary" }))}
href="/api/proxy/v1/checkout"
message="Sign In or Sign Up to add funds to your balance"
>
<HandCard className="text-xs text-accent-foreground" />
<span className="m-2 whitespace-nowrap text-accent-foreground">Add Funds</span>
</LoginRequiredLink>
<TopUpAmountPicker popoverClassName="absolute md:min-w-max">
<LoginRequiredLink
className={cn("hover:no-underline", buttonVariants({ variant: "outline", className: "mr-2 border-primary" }))}
href="/api/proxy/v1/checkout"
message="Sign In or Sign Up to add funds to your balance"
>
<HandCard className="text-xs text-accent-foreground" />
<span className="m-2 whitespace-nowrap text-accent-foreground">Add Funds</span>
</LoginRequiredLink>
</TopUpAmountPicker>
)}
<Button variant="default" onClick={handleNext}>
Next
Expand Down
19 changes: 11 additions & 8 deletions apps/deploy-web/src/components/home/YourAccount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useAtom } from "jotai";
import Link from "next/link";
import { useTheme } from "next-themes";

import { TopUpAmountPicker } from "@src/components/top-up-amount-picker/TopUpAmountPicker";
import { LoginRequiredLink } from "@src/components/user/LoginRequiredLink";
import { browserEnvConfig } from "@src/config/browser-env.config";
import { UAKT_DENOM } from "@src/config/denom.config";
Expand Down Expand Up @@ -229,14 +230,16 @@ export const YourAccount: React.FunctionComponent<Props> = ({ isLoadingBalances,
</Link>
)}
{isManagedWallet && (
<LoginRequiredLink
className={cn("mt-4", buttonVariants({ variant: "default" }))}
href="/api/proxy/v1/checkout"
message="Sign In or Sign Up to add funds to your balance"
>
Add Funds
<HandCard className="ml-4 rotate-45 text-sm" />
</LoginRequiredLink>
<TopUpAmountPicker>
<LoginRequiredLink
className={cn(buttonVariants({ variant: "default" }))}
href="/api/proxy/v1/checkout"
message="Sign In or Sign Up to add funds to your balance"
>
Add Funds
<HandCard className="ml-4 rotate-45 text-sm" />
</LoginRequiredLink>
</TopUpAmountPicker>
)}
</div>

Expand Down
Loading

0 comments on commit 73ccf7c

Please sign in to comment.