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 1 commit
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
111 changes: 111 additions & 0 deletions src/stripe-webhook/stripe-webhook.controller.ts
@@ -0,0 +1,111 @@
import { ConfigService } from "@nestjs/config";
import { BadRequestException, Controller, 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 {
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) {
console.error(e);
throw (new BadRequestException);
}

console.log(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`);
brandonroberts marked this conversation as resolved.
Show resolved Hide resolved
}

@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)) {
console.log("event type", 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
142 changes: 142 additions & 0 deletions src/subscription/stripe-subscription.dto.ts
@@ -0,0 +1,142 @@
import { Entity, BaseEntity, PrimaryColumn, Column, CreateDateColumn } from "typeorm";

import {
ApiModelProperty,
ApiModelPropertyOptional,
} from "@nestjs/swagger/dist/decorators/api-model-property.decorator";
import { ApiHideProperty } from "@nestjs/swagger";

@Entity({ name: "subscriptions" })
export class DbSubscription extends BaseEntity {
@ApiModelProperty({
description: "Subscription identifier",
example: "sub_1234",
})
@PrimaryColumn("text")
public id!: string;

@ApiModelProperty({
description: "User identifier",
example: 42211,
})
@Column({ type: "bigint" })
public user_id!: number;

@ApiModelProperty({
description: "Subscription Status",
example: "active",
})
@Column({
type: "text",
default: "active",
})
public status!: string;

@ApiHideProperty()
@Column({
type: "text",
select: false,
})
public metadata!: string;

@ApiModelProperty({
description: "Price ID",
example: "price_12345",
})
@Column({ type: "text" })
public price_id!: string;

@ApiModelProperty({
description: "Quantity",
example: 1,
})
@Column({ type: "bigint" })
public quantity!: number;

@ApiModelPropertyOptional({
description: "Timestamp representing subscription creation",
example: "2016-10-19 13:24:51.000000",
})
@Column({ type: "boolean" })
public cancel_at_period_end!: boolean;

@ApiModelPropertyOptional({
description: "Timestamp representing subscription creation",
example: "2016-10-19 13:24:51.000000",
})
@CreateDateColumn({
type: "timestamp without time zone",
default: () => "now()",
})
public created_at?: Date;

@ApiModelPropertyOptional({
description: "Timestamp representing current period start date",
example: "2016-10-19 13:24:51.000000",
})
@Column({
type: "timestamp without time zone",
default: () => "now()",
})
public current_period_start_at?: Date;

@ApiModelPropertyOptional({
description: "Timestamp representing current period end date",
example: "2016-10-19 13:24:51.000000",
})
@Column({
type: "timestamp without time zone",
default: () => "now()",
})
public current_period_end_at?: Date;

@ApiModelPropertyOptional({
description: "Timestamp representing end date",
example: "2016-10-19 13:24:51.000000",
})
@Column({
type: "timestamp without time zone",
default: () => "now()",
})
public ended_at?: Date;

@ApiModelPropertyOptional({
description: "Timestamp representing cancel date",
example: "2016-10-19 13:24:51.000000",
})
@Column({
type: "timestamp without time zone",
default: () => "now()",
})
public cancel_at?: Date;

@ApiModelPropertyOptional({
description: "Timestamp representing canceled date",
example: "2016-10-19 13:24:51.000000",
})
@Column({
type: "timestamp without time zone",
default: () => "now()",
})
public canceled_at?: Date;

@ApiModelPropertyOptional({
description: "Timestamp representing trial start date",
example: "2016-10-19 13:24:51.000000",
})
@Column({
type: "timestamp without time zone",
default: () => "now()",
})
public trial_start_at?: Date;

@ApiModelPropertyOptional({
description: "Timestamp representing trial end date",
example: "2016-10-19 13:24:51.000000",
})
@Column({
type: "timestamp without time zone",
default: () => "now()",
})
public trial_end_at?: Date;
}