Skip to content

Commit

Permalink
added credits feature
Browse files Browse the repository at this point in the history
  • Loading branch information
imolorhe committed Jul 15, 2024
1 parent 183eb23 commit 760b25a
Show file tree
Hide file tree
Showing 29 changed files with 2,014 additions and 785 deletions.
23 changes: 22 additions & 1 deletion DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,17 @@ docker run -p 3000:3000 test-demo

If using Cloudflare DNS, you need to setup full SSL mode instead of flexible mode

### Stripe product requirements
### Stripe (altair subscription) product requirements

- Always create plan config in the database first
- Product should have `role` metadata that corresponds to `PlanConfig` id in the database
- Product should have `type` metadata set to `plan`
- Product should have recurring pricing

### Stripe credit product requirements

- Create a product with `type` metadata set to `credit`

<!-- background:linear-gradient(135deg,#00F5A0 0%,#00D9F5 100%); -->

### Signing MacOS app
Expand All @@ -119,3 +124,19 @@ https://www.codiga.io/blog/notarize-sign-electron-app/
### Stripe listen throwing api_key_expired error

Login to stripe CLI using `stripe login` and it should work again

### Running `prisma migrate dev` wants to wipe all data

- Backup the data before running the command.
- Remove all database and table creation commands from the backup file.
- Run the migration command.
- Restore it after the command.
- Run the restore command multiple times if it fails due to foreign key constraints, until all foreign key constraints are replaced with duplicate key value errors.

```bash
env $(cat .env | xargs) pg_dump --dbname=altairgraphql-db --port=5432 --host=localhost --username=my_db_user > data.sql

yarn prisma migrate dev

psql --file=data.sql --dbname=altairgraphql-db --port=5432 --host=localhost --username=my_db_user
```
2 changes: 1 addition & 1 deletion bin/dev.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/bin/bash

set -e
set -euo pipefail

SCRIPT_DIR=$(dirname "$0")
ROOT=$(cd "$SCRIPT_DIR/../" && pwd)/
Expand Down
3 changes: 3 additions & 0 deletions packages/altair-api-utils/src/credit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface IBuyCreditDto {
quantity?: number;
}
1 change: 1 addition & 0 deletions packages/altair-api-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './client';
export * from './team';
export * from './user';
export * from './workspace';
export * from './credit';
86 changes: 44 additions & 42 deletions packages/altair-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,65 +6,66 @@
"dependencies": {
"@altairgraphql/api-utils": "^7.2.4",
"@altairgraphql/db": "^7.2.4",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.0.0",
"@nestjs/event-emitter": "^2.0.2",
"@nestjs/jwt": "^9.0.0",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/swagger": "^6.1.3",
"@nestjs/common": "^10.3.10",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.3.10",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.10",
"@nestjs/schedule": "^4.1.0",
"@nestjs/swagger": "^7.4.0",
"@newrelic/pino-enricher": "^1.1.1",
"altair-graphql-core": "^7.2.4",
"bcrypt": "^5.1.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"google-auth-library": "^8.7.0",
"nestjs-pino": "^3.1.2",
"nestjs-prisma": "^0.19.0",
"newrelic": "^11.2.1",
"passport": "^0.6.0",
"passport": "^0.7.0",
"passport-google-oauth": "^2.0.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.0",
"pino-http": "^8.5.0",
"passport-jwt": "^4.0.1",
"pino-http": "^10.2.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^5.0.5",
"rxjs": "^7.0.0",
"stripe": "^13.7.0",
"swagger-ui-express": "^4.5.0"
"rxjs": "^7.8.1",
"stripe": "^16.2.0",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@types/bcrypt": "^5.0.0",
"@types/express": "^4.17.13",
"@types/jest": "28.1.8",
"@types/newrelic": "^9.4.0",
"@types/node": "^16.0.0",
"@types/passport-google-oauth": "^1.0.42",
"@types/passport-google-oauth20": "^2.0.11",
"@types/passport-jwt": "^3.0.7",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^6.16.0",
"@typescript-eslint/parser": "^6.16.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"@nestjs/cli": "^10.4.2",
"@nestjs/schematics": "^10.1.2",
"@nestjs/testing": "^10.3.10",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/newrelic": "^9.14.4",
"@types/node": "^20.14.10",
"@types/passport-google-oauth": "^1.0.45",
"@types/passport-google-oauth20": "^2.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"eslint": "^9.6.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"jest": "29.4.1",
"jest": "^29.7.0",
"passport-custom": "^1.1.1",
"pino-pretty": "^9.2.0",
"prettier": "^3.2.5",
"prisma": "^4.9.0",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"pino-pretty": "^11.2.1",
"prettier": "^3.3.2",
"prisma": "^5.16.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"sync-dotenv": "^2.7.0",
"ts-jest": "29.0.5",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"tsconfig-paths": "4.1.0",
"typescript": "5.1.6"
"ts-jest": "^29.2.2",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.3"
},
"license": "UNLICENSED",
"private": true,
Expand All @@ -74,6 +75,7 @@
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"nest:generate:resource": "nest g resource",
"nest:upgrade": "npx yarn-upgrade-all --ignore '^((?!@nestjs).)*$' --upgrade",
"prebuild": "rimraf dist",
"start": "nest start",
"start:debug": "nest start --debug --watch",
Expand Down
4 changes: 4 additions & 0 deletions packages/altair-api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { LoggerModule } from 'nestjs-pino';
import { StripeModule } from './stripe/stripe.module';
import { StripeWebhookController } from './stripe-webhook/stripe-webhook.controller';
import { WorkspacesModule } from './workspaces/workspaces.module';
import { CreditModule } from './credit/credit.module';
import { ScheduleModule } from '@nestjs/schedule';

if (process.env.NEW_RELIC_APP_NAME && process.env.NODE_ENV === 'production') {
// eslint-disable-next-line @typescript-eslint/no-var-requires
Expand All @@ -34,13 +36,15 @@ if (process.env.NEW_RELIC_APP_NAME && process.env.NODE_ENV === 'production') {
EventEmitterModule.forRoot({
verboseMemoryLeak: true,
}),
ScheduleModule.forRoot(),
AuthModule,
QueriesModule,
QueryCollectionsModule,
TeamsModule,
TeamMembershipsModule,
StripeModule,
WorkspacesModule,
CreditModule,
],
controllers: [AppController, StripeWebhookController],
providers: [AppService, PasswordService],
Expand Down
7 changes: 1 addition & 6 deletions packages/altair-api/src/auth/strategies/google.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IAuthModuleOptions, PassportStrategy } from '@nestjs/passport';
import { IdentityProvider, User } from '@altairgraphql/db';
import { Request } from 'express';
import { PrismaService } from 'nestjs-prisma';
import { Profile, Strategy } from 'passport-google-oauth20';
import { AuthService } from '../auth.service';
import { UserService } from '../user/user.service';
Expand Down
110 changes: 108 additions & 2 deletions packages/altair-api/src/auth/user/user.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { BASIC_PLAN_ID, PRO_PLAN_ID, User } from '@altairgraphql/db';
import {
BASIC_PLAN_ID,
CreditTransactionType,
INITIAL_CREDIT_BALANCE,
PRO_PLAN_ID,
User,
} from '@altairgraphql/db';
import { ConflictException, Injectable, Logger } from '@nestjs/common';
import { PrismaService } from 'nestjs-prisma';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime';
Expand Down Expand Up @@ -33,11 +39,41 @@ export class UserService {
...payload,
stripeCustomerId: stripeCustomer.id,
// password: hashedPassword,

// create user workspace
Workspace: {
create: {
name: 'My workspace',
},
},

// create user plan config
UserPlan: {
create: {
planRole: BASIC_PLAN_ID,
quantity: 1,
},
},

// create user credit balance
CreditBalance: {
create: {
fixedCredits: INITIAL_CREDIT_BALANCE,
monthlyCredits: 0,
},
},

// create user credit transaction
CreditTransaction: {
create: {
type: CreditTransactionType.INITIAL,
fixedAmount: INITIAL_CREDIT_BALANCE,
monthlyAmount: 0,
description: 'Initial credits',
},
},

// create user credential if provider info is provided
...(providerInfo
? {
UserCredential: {
Expand Down Expand Up @@ -185,11 +221,81 @@ export class UserService {
if (!proPlanInfo) {
throw new Error(`No plan info found for id: ${PRO_PLAN_ID}`);
}
const session = await this.stripeService.createCheckoutSession(
const session = await this.stripeService.createSubscriptionCheckoutSession(
customerId,
proPlanInfo.priceId
);

return session.url;
}

async updateUserPlan(userId: string, planId: string, quantity: number) {
const user = await this.mustGetUser(userId);

await this.prisma.userPlan.upsert({
where: {
userId: user.id,
},
create: {
userId: user.id,
planRole: planId,
quantity,
},
update: {
planRole: planId,
quantity,
},
});

return this.updateSubscriptionQuantity(userId, quantity);
}

async toBasicPlan(userId: string) {
await this.updateUserPlan(userId, BASIC_PLAN_ID, 1);

// Deduct remaining monthly credits
const creditBalance = await this.prisma.creditBalance.findUnique({
where: { userId },
});

if (!creditBalance) {
throw new Error('User has no credit balance');
}

const remainingMonthlyCredits = creditBalance.monthlyCredits;

if (remainingMonthlyCredits > 0) {
await this.prisma.creditBalance.update({
where: { userId },
data: {
monthlyCredits: 0,
},
});

// Create CreditTransaction record (type: downgraded) with deducted amount
await this.prisma.creditTransaction.create({
data: {
userId,
monthlyAmount: remainingMonthlyCredits,
fixedAmount: 0,
type: CreditTransactionType.DOWNGRADED,
description: 'Downgraded to basic plan',
},
});
}
}

async toProPlan(userId: string, quantity: number) {
await this.updateUserPlan(userId, PRO_PLAN_ID, quantity);
}

async getProUsers() {
return this.prisma.user.findMany({
where: {
UserPlan: {
planRole: PRO_PLAN_ID,
},
},
});
}
}
20 changes: 20 additions & 0 deletions packages/altair-api/src/credit/credit.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CreditController } from './credit.controller';
import { CreditService } from './credit.service';

describe('CreditController', () => {
let controller: CreditController;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CreditController],
providers: [CreditService],
}).compile();

controller = module.get<CreditController>(CreditController);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});
});
25 changes: 25 additions & 0 deletions packages/altair-api/src/credit/credit.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { CreditService } from './credit.service';
import { ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { Request } from 'express';
import { BuyDto } from './dto/buy.dto';

@Controller('credit')
@ApiTags('Credits')
@UseGuards(JwtAuthGuard)
export class CreditController {
constructor(private readonly creditService: CreditService) {}

@Get()
async getAvailableCredits(@Req() req: Request) {
const userId = req?.user?.id ?? '';
return this.creditService.getAvailableCredits(userId);
}

@Post('buy')
async buyCredits(@Req() req: Request, @Body() buyDto: BuyDto) {
const userId = req?.user?.id ?? '';
return this.creditService.buyCredits(userId, buyDto.quantity);
}
}
Loading

0 comments on commit 760b25a

Please sign in to comment.