Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add stripe webhook integration for subscriptions #74

Merged
merged 3 commits into from Dec 21, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .envrc
Expand Up @@ -21,3 +21,4 @@ STRIPE_SECRET_KEY=test
STRIPE_SUBSCRIPTION_PRICE_ID=price_test
STRIPE_CHECKOUT_SESSION_SUCCESS_URL=http://localhost:3001?subscription_success=true
STRIPE_CHECKOUT_SESSION_CANCEL_URL=http://localhost:3001?subscription_cancel=true
STRIPE_WEBHOOK_SECRET=test
8 changes: 8 additions & 0 deletions src/app.module.ts
Expand Up @@ -12,6 +12,7 @@ import { RepoModule } from "./repo/repo.module";
import apiConfig from "./config/api.config";
import dbConfig from "./config/database.config";
import endpointConfig from "./config/endpoint.config";
import stripeConfig from "./config/stripe.config";
import { HealthModule } from "./health/health.module";
import { DbRepo } from "./repo/entities/repo.entity";
import { DbUser } from "./user/user.entity";
Expand All @@ -37,6 +38,9 @@ import { UserReposModule } from "./user-repo/user-repos.module";
import { DbUserRepo } from "./user-repo/user-repo.entity";
import { DbCustomer } from "./customer/customer.entity";
import { CustomerModule } from "./customer/customer.module";
import { StripeWebHookModule } from "./stripe-webhook/webhook.module";
import { StripeSubscriptionModule } from "./subscription/stripe-subscription.module";
import { DbSubscription } from "./subscription/stripe-subscription.dto";

@Module({
imports: [
Expand All @@ -45,6 +49,7 @@ import { CustomerModule } from "./customer/customer.module";
apiConfig,
dbConfig,
endpointConfig,
stripeConfig,
],
isGlobal: true,
}),
Expand All @@ -70,6 +75,7 @@ import { CustomerModule } from "./customer/customer.module";
DbInsight,
DbInsightRepo,
DbCustomer,
DbSubscription,
],
synchronize: false,
logger: (new DatabaseLoggerMiddleware),
Expand Down Expand Up @@ -114,6 +120,8 @@ import { CustomerModule } from "./customer/customer.module";
InsightsModule,
UserReposModule,
CustomerModule,
StripeWebHookModule,
StripeSubscriptionModule,
],
controllers: [],
providers: [],
Expand Down
9 changes: 9 additions & 0 deletions src/config/stripe.config.ts
@@ -0,0 +1,9 @@
import { registerAs } from "@nestjs/config";

export default registerAs("stripe", () => ({
secretKey: String(process.env.STRIPE_SECRET_KEY ?? ""),
webhookSecret: String(process.env.STRIPE_WEBHOOK_SECRET_LIVE ?? process.env.STRIPE_WEBHOOK_SECRET ?? ""),
subscriptionPriceID: String(process.env.STRIPE_SUBSCRIPTION_PRICE_ID ?? ""),
subscriptionSessionCheckoutSuccessURL: String(process.env.STRIPE_CHECKOUT_SESSION_SUCCESS_URL ?? ""),
subscriptionSessionCancelURL: String(process.env.STRIPE_CHECKOUT_SESSION_CANCEL_URL ?? ""),
}));
9 changes: 9 additions & 0 deletions src/customer/customer.service.ts
Expand Up @@ -24,6 +24,15 @@ export class CustomerService {
return queryBuilder.getOne();
}

async findByCustomerId (id: string) {
const queryBuilder = this.baseQueryBuilder();

queryBuilder
.where("customer.stripe_customer_id=:id", { id });

return queryBuilder.getOne();
}

async addCustomer (userId: number, stripe_customer_id: string) {
return this.customerRepository.save({ id: userId, stripe_customer_id });
}
Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Expand Up @@ -20,7 +20,7 @@ async function bootstrap () {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: false }),
{ bufferLogs: true },
{ bufferLogs: true, rawBody: true },
);
const configService = app.get(ConfigService);
const apiDomain = String(configService.get("api.domain"));
Expand Down
112 changes: 112 additions & 0 deletions src/stripe-webhook/stripe-webhook.controller.ts
@@ -0,0 +1,112 @@
import { ConfigService } from "@nestjs/config";
import { BadRequestException, Controller, Logger, Post, RawBodyRequest, Req } from "@nestjs/common";
import { ApiOkResponse, ApiTags } from "@nestjs/swagger";
import Stripe from "stripe";

import { toDateTime } from "./utils";
import { CustomerService } from "../customer/customer.service";
import { StripeSubscriptionService } from "../subscription/stripe-subscription.service";
import { StripeService } from "../stripe/stripe.service";
import { UserService } from "../user/user.service";

const relevantEvents = new Set([
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
]);

@ApiTags("Stripe service")
@Controller("stripe")
export class StripeWebhookController {
private logger = new Logger(`StripeWebhook`);

constructor (
private customerService: CustomerService,
private stripeSubscriptionService: StripeSubscriptionService,
private stripeService: StripeService,
private configService: ConfigService,
private userService: UserService,
) {}

private async manageSubscriptionStatusChange (subscriptionId: string, customerId: string) {
const customerData = await this.customerService.findByCustomerId(customerId);

if (!customerData) {
throw (new BadRequestException);
}

const { id: uuid } = customerData;
const userId = parseInt(`${uuid}`, 10);
const subscription = await this.stripeService.stripe.subscriptions.retrieve(subscriptionId, { expand: ["default_payment_method"] });

// upsert the latest status of the subscription object.
const subscriptionData = {
id: subscription.id,
user_id: userId,
metadata: JSON.stringify(subscription.metadata),
status: subscription.status as string,
price_id: subscription.items.data[0].price.id,
quantity: subscription.items.data.length,
cancel_at_period_end: subscription.cancel_at_period_end,
cancel_at: subscription.cancel_at ? toDateTime(subscription.cancel_at) : undefined,
canceled_at: subscription.canceled_at ? toDateTime(subscription.canceled_at) : undefined,
current_period_start_at: toDateTime(subscription.current_period_start),
current_period_end_at: toDateTime(subscription.current_period_end),
created_at: toDateTime(subscription.created),
ended_at: subscription.ended_at ? toDateTime(subscription.ended_at) : undefined,
trial_start_at: subscription.trial_start ? toDateTime(subscription.trial_start) : undefined,
trial_end_at: subscription.trial_end ? toDateTime(subscription.trial_end) : undefined,
};

try {
await this.stripeSubscriptionService.upsertSubscription(subscriptionData);

const userRole = subscription.status === "active" ? 50 : 10;

await this.userService.updateRole(userId, userRole);
} catch (e: unknown) {
this.logger.error(`Error inserting/updating subscription [${subscription.id}] for user [${userId}]: ${(e as Error).toString()}`);
throw (new BadRequestException);
}

this.logger.log(`Inserted/updated subscription [${subscription.id}] for user [${userId}]`);
}

@Post("/webhooks")
@ApiOkResponse()
async handleStripeWebhook (@Req() req: RawBodyRequest<Request>) {
const sig = (req.headers as unknown as Record<string, string>)["stripe-signature"];
const webhookSecret: string | undefined = this.configService.get("stripe.webhookSecret");

if (!sig || !webhookSecret) {
return;
}

const event = this.stripeService.stripe.webhooks.constructEvent(req.rawBody!, sig, webhookSecret);

if (relevantEvents.has(event.type)) {
const subEvents = [
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
];

if (subEvents.includes(event.type)) {
const subscription = event.data.object as Stripe.Subscription;

await this.manageSubscriptionStatusChange(subscription.id, subscription.customer as string);
} else if (event.type === "checkout.session.completed") {
const checkoutSession = event.data.object as Stripe.Checkout.Session;

if (checkoutSession.mode === "subscription") {
const subscriptionId = checkoutSession.subscription;

await this.manageSubscriptionStatusChange(subscriptionId as string, checkoutSession.customer as string);
}
} else {
throw (new BadRequestException);
}
}
}
}
7 changes: 7 additions & 0 deletions src/stripe-webhook/utils.ts
@@ -0,0 +1,7 @@
export const toDateTime = (secs: number) => {
// unix epoch start
const t = new Date("1970-01-01T00:30:00Z");

t.setSeconds(secs);
return t;
};
21 changes: 21 additions & 0 deletions src/stripe-webhook/webhook.module.ts
@@ -0,0 +1,21 @@
import { Module } from "@nestjs/common";

import { StripeSubscriptionModule } from "../subscription/stripe-subscription.module";
import { CustomerModule } from "../customer/customer.module";

import { StripeWebhookController } from "./stripe-webhook.controller";
import { StripeModule } from "../stripe/stripe.module";
import { UserModule } from "../user/user.module";

@Module({
imports: [
StripeSubscriptionModule,
CustomerModule,
StripeModule,
UserModule,
],
providers: [StripeWebhookController],
controllers: [StripeWebhookController],
exports: [StripeWebhookController],
})
export class StripeWebHookModule {}
18 changes: 12 additions & 6 deletions src/stripe/stripe.service.ts
Expand Up @@ -5,10 +5,16 @@ import Stripe from "stripe";

@Injectable()
export class StripeService {
private stripe: Stripe;
private _stripe?: Stripe;

constructor (private configService: ConfigService) {
this.stripe = new Stripe(this.configService.get("STRIPE_SECRET_KEY")!, { apiVersion: "2022-11-15" });
constructor (private configService: ConfigService) {}

get stripe () {
if (!this._stripe) {
this._stripe = new Stripe(this.configService.get("stripe.secretKey")!, { apiVersion: "2022-11-15" });
}

return this._stripe;
}

async addCustomer (id: number, email?: string) {
Expand All @@ -25,13 +31,13 @@ export class StripeService {
customer,
line_items: [
{
price: this.configService.get("STRIPE_SUBSCRIPTION_PRICE_ID")!,
price: this.configService.get("stripe.subscriptionPriceID")!,
quantity: 1,
},
],
mode: "subscription",
success_url: `${this.configService.get<string>("STRIPE_CHECKOUT_SESSION_SUCCESS_URL")!}`,
cancel_url: `${this.configService.get<string>("STRIPE_CHECKOUT_SESSION_CANCEL_URL")!}`,
success_url: `${this.configService.get<string>("stripe.subscriptionSessionCheckoutSuccessURL")!}`,
cancel_url: `${this.configService.get<string>("stripe.subscriptionSessionCancelURL")!}`,
});

return { sessionId: session.id };
Expand Down