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

added credits feature #2591

Merged
merged 3 commits into from
Jul 15, 2024
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
2 changes: 2 additions & 0 deletions packages/altair-api/jest.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// declare global {
// }
declare namespace jest {
interface Matchers<R> {
toBeUser(): R;
Expand Down
86 changes: 44 additions & 42 deletions packages/altair-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,74 +6,76 @@
"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",
"@nestjs/cli": "^10.4.2",
"@nestjs/schematics": "^10.1.2",
"@nestjs/testing": "^10.3.10",
"@types/bcrypt": "^5.0.2",
"@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",
"@types/jest": "^28.1.8",
"@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.4.1",
"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.0.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.3"
},
"license": "UNLICENSED",
"private": true,
"scripts": {
"build": "nest build",
"build": "tsc --noEmit && nest build",
"env": "sync-dotenv",
"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
2 changes: 1 addition & 1 deletion packages/altair-api/src/app-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const bootstrapApp = async (app: INestApplication) => {

// Cors
if (corsConfig?.enabled) {
app.enableCors();
app.enableCors({ origin: true });
}

return app;
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
9 changes: 6 additions & 3 deletions packages/altair-api/src/auth/user/user.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '../mocks/stripe-service.mock';
import Stripe from 'stripe';
import { PRO_PLAN_ID } from '@altairgraphql/db';
// import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals';

describe('UserService', () => {
let service: UserService;
Expand Down Expand Up @@ -250,9 +251,11 @@ describe('UserService', () => {
jest
.spyOn(stripeService, 'getPlanInfoByRole')
.mockResolvedValueOnce(mockPlanInfo());
jest.spyOn(stripeService, 'createCheckoutSession').mockResolvedValueOnce({
url: urlMock,
} as Stripe.Response<Stripe.Checkout.Session>);
jest
.spyOn(stripeService, 'createSubscriptionCheckoutSession')
.mockResolvedValueOnce({
url: urlMock,
} as Stripe.Response<Stripe.Checkout.Session>);

// WHEN
const url = await service.getProPlanUrl(userMock.id);
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,
},
},
});
}
}
Loading
Loading