Stripe Pricing + Subscriptions

This commit is contained in:
Andreas Knuth 2024-08-21 21:13:43 +02:00
parent 48bff89526
commit b4609d07ba
16 changed files with 969 additions and 1035 deletions

File diff suppressed because it is too large Load Diff

View File

@ -25,10 +25,6 @@ import { UserModule } from './user/user.module.js';
// const __dirname = path.dirname(__filename); // const __dirname = path.dirname(__filename);
function loadEnvFiles() { function loadEnvFiles() {
// Load the .env file
dotenv.config();
console.log('Loaded .env file');
// Determine which additional env file to load // Determine which additional env file to load
let envFilePath = ''; let envFilePath = '';
const host = process.env.HOST_NAME || ''; const host = process.env.HOST_NAME || '';
@ -48,6 +44,13 @@ function loadEnvFiles() {
} else { } else {
console.log(`No additional .env file found for HOST_NAME: ${host}`); console.log(`No additional .env file found for HOST_NAME: ${host}`);
} }
// Load the .env file
dotenv.config();
console.log('Loaded .env file');
// Output all loaded environment variables
console.log('Loaded environment variables:');
console.log(JSON.stringify(process.env, null, 2));
} }
loadEnvFiles(); loadEnvFiles();

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import ky from 'ky'; import ky from 'ky';
import { KeycloakUser } from 'src/models/main.model';
import urlcat from 'urlcat'; import urlcat from 'urlcat';
@Injectable() @Injectable()
@ -37,7 +38,7 @@ export class AuthService {
} }
} }
public async getUsers() { public async getUsers(): Promise<KeycloakUser[]> {
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const URL = `${process.env.host}${process.env.usersURL}`; const URL = `${process.env.host}${process.env.usersURL}`;
const response = await ky const response = await ky
@ -48,7 +49,7 @@ export class AuthService {
}, },
}) })
.json(); .json();
return response; return response as KeycloakUser[];
} }
public async getUser(userid: string) { public async getUser(userid: string) {
const token = await this.getAccessToken(); const token = await this.getAccessToken();

View File

@ -1,3 +1,4 @@
import Stripe from 'stripe';
import { BusinessListing, CommercialPropertyListing, Sender, User } from './db.model.js'; import { BusinessListing, CommercialPropertyListing, Sender, User } from './db.model.js';
import { State } from './server.model.js'; import { State } from './server.model.js';
@ -258,6 +259,7 @@ export interface ModalResult {
export interface Checkout { export interface Checkout {
priceId: string; priceId: string;
email: string; email: string;
name: string;
} }
export function isEmpty(value: any): boolean { export function isEmpty(value: any): boolean {
// Check for undefined or null // Check for undefined or null
@ -298,7 +300,7 @@ export interface ValidationMessage {
field: string; field: string;
message: string; message: string;
} }
export function createDefaultUser(email: string, firstname: string, lastname: string, subscriptionPlan: 'free' | 'professional' | 'broker'): User { export function createDefaultUser(email: string, firstname: string, lastname: string, subscriptionPlan: 'professional' | 'broker'): User {
return { return {
id: undefined, id: undefined,
email, email,
@ -374,3 +376,4 @@ export function createDefaultBusinessListing(): BusinessListing {
listingsCategory: 'business', listingsCategory: 'business',
}; };
} }
export type StripeSubscription = Stripe.Subscription;

View File

@ -1,4 +1,4 @@
import { Body, Controller, HttpException, HttpStatus, Post, Req, Res } from '@nestjs/common'; import { Body, Controller, Get, HttpException, HttpStatus, Param, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { Checkout } from 'src/models/main.model.js'; import { Checkout } from 'src/models/main.model.js';
import Stripe from 'stripe'; import Stripe from 'stripe';
@ -13,8 +13,8 @@ export class PaymentController {
// return this.paymentService.createSubscription(subscriptionData); // return this.paymentService.createSubscription(subscriptionData);
// } // }
@Post('create-checkout-session') @Post('create-checkout-session')
async calculateTax(@Body() checkout: Checkout) { async createCheckoutSession(@Body() checkout: Checkout) {
return this.paymentService.checkout(checkout); return this.paymentService.createCheckoutSession(checkout);
} }
@Post('webhook') @Post('webhook')
async handleWebhook(@Req() req: Request, @Res() res: Response): Promise<void> { async handleWebhook(@Req() req: Request, @Res() res: Response): Promise<void> {
@ -33,4 +33,8 @@ export class PaymentController {
throw new HttpException('Webhook Error', HttpStatus.BAD_REQUEST); throw new HttpException('Webhook Error', HttpStatus.BAD_REQUEST);
} }
} }
@Get('subscriptions/:email')
async findSubscriptionsById(@Param('email') email: string): Promise<any> {
return await this.paymentService.getSubscription(email);
}
} }

View File

@ -1,4 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { AuthService } from '../auth/auth.service.js';
import { DrizzleModule } from '../drizzle/drizzle.module.js'; import { DrizzleModule } from '../drizzle/drizzle.module.js';
import { FileService } from '../file/file.service.js'; import { FileService } from '../file/file.service.js';
import { GeoService } from '../geo/geo.service.js'; import { GeoService } from '../geo/geo.service.js';
@ -10,8 +12,8 @@ import { PaymentController } from './payment.controller.js';
import { PaymentService } from './payment.service.js'; import { PaymentService } from './payment.service.js';
@Module({ @Module({
imports: [DrizzleModule, UserModule, MailModule], imports: [DrizzleModule, UserModule, MailModule, AuthModule],
providers: [PaymentService, UserService, MailService, FileService, GeoService], providers: [PaymentService, UserService, MailService, FileService, GeoService, AuthService],
controllers: [PaymentController], controllers: [PaymentController],
}) })
export class PaymentModule {} export class PaymentModule {}

View File

@ -3,6 +3,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver.js';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { Logger } from 'winston'; import { Logger } from 'winston';
import { AuthService } from '../auth/auth.service.js';
import * as schema from '../drizzle/schema.js'; import * as schema from '../drizzle/schema.js';
import { PG_CONNECTION } from '../drizzle/schema.js'; import { PG_CONNECTION } from '../drizzle/schema.js';
import { MailService } from '../mail/mail.service.js'; import { MailService } from '../mail/mail.service.js';
@ -21,13 +22,33 @@ export class PaymentService {
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>, @Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
private readonly userService: UserService, private readonly userService: UserService,
private readonly mailService: MailService, private readonly mailService: MailService,
private readonly authService: AuthService,
) { ) {
this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-06-20', apiVersion: '2024-06-20',
}); });
} }
async checkout(checkout: Checkout) { async createCheckoutSession(checkout: Checkout) {
try { try {
let customerId;
const existingCustomers = await this.stripe.customers.list({
email: checkout.email,
limit: 1,
});
if (existingCustomers.data.length > 0) {
// Kunde existiert
customerId = existingCustomers.data[0].id;
} else {
// Kunde existiert nicht, neuen Kunden erstellen
const newCustomer = await this.stripe.customers.create({
email: checkout.email,
name: checkout.name,
});
customerId = newCustomer.id;
}
const price = await this.stripe.prices.retrieve(checkout.priceId);
if (price.product) {
const product = await this.stripe.products.retrieve(price.product as string);
const session = await this.stripe.checkout.sessions.create({ const session = await this.stripe.checkout.sessions.create({
mode: 'subscription', mode: 'subscription',
payment_method_types: ['card'], payment_method_types: ['card'],
@ -39,15 +60,26 @@ export class PaymentService {
], ],
success_url: 'http://localhost:4200/success', success_url: 'http://localhost:4200/success',
cancel_url: 'http://localhost:4200/pricing', cancel_url: 'http://localhost:4200/pricing',
customer_email: checkout.email, // customer_email: checkout.email,
customer: customerId,
// customer_details:{
// name: checkout.name, // Hier wird der Name des Kunden übergeben
// },
shipping_address_collection: { shipping_address_collection: {
allowed_countries: ['US'], allowed_countries: ['US'],
}, },
client_reference_id: '1234', client_reference_id: '1234',
locale: 'en', locale: 'en',
subscription_data: {
trial_end: Math.floor(new Date().setMonth(new Date().getMonth() + 3) / 1000),
},
metadata: { plan: product.name },
}); });
return session; return session;
} else {
return null;
}
} catch (e) { } catch (e) {
throw new BadRequestException(`error during checkout: ${e}`); throw new BadRequestException(`error during checkout: ${e}`);
} }
@ -57,8 +89,15 @@ export class PaymentService {
} }
async handleCheckoutSessionCompleted(session: Stripe.Checkout.Session): Promise<void> { async handleCheckoutSessionCompleted(session: Stripe.Checkout.Session): Promise<void> {
this.logger.info(JSON.stringify(session)); this.logger.info(JSON.stringify(session));
//const jwtUser:JwtUser = {firstname:,lastname:} const keycloakUsers = await this.authService.getUsers();
const user = await this.userService.getUserByMail(session.customer_details.email); const keycloakUser = keycloakUsers.find(u => u.email === session.customer_details.email);
const user = await this.userService.getUserByMail(session.customer_details.email, {
userId: keycloakUser.id,
firstname: keycloakUser.firstName,
lastname: keycloakUser.lastName,
username: keycloakUser.email,
roles: [],
});
user.stripeCustomerId = session.customer as string; user.stripeCustomerId = session.customer as string;
user.subscriptionId = session.subscription as string; user.subscriptionId = session.subscription as string;
user.planActive = true; user.planActive = true;
@ -78,4 +117,20 @@ export class PaymentService {
this.userService.saveUser(user); this.userService.saveUser(user);
this.mailService.sendSubscriptionConfirmation(user); this.mailService.sendSubscriptionConfirmation(user);
} }
async getSubscription(email: string): Promise<Stripe.Subscription[]> {
const existingCustomers = await this.stripe.customers.list({
email: email,
limit: 1,
});
if (existingCustomers.data.length > 0) {
const subscriptions = await this.stripe.subscriptions.list({
customer: existingCustomers.data[0].id,
status: 'all', // Optional: Gibt Abos in allen Status zurück, wie 'active', 'canceled', etc.
limit: 20, // Optional: Begrenze die Anzahl der zurückgegebenen Abonnements
});
return subscriptions.data.filter(s => s.status === 'active' || s.status === 'trialing');
} else {
return [];
}
}
} }

View File

@ -96,7 +96,7 @@ export class UserService {
.from(schema.users) .from(schema.users)
.where(sql`email = ${email}`)) as User[]; .where(sql`email = ${email}`)) as User[];
if (users.length === 0) { if (users.length === 0) {
const user: User = { id: undefined, customerType: 'buyer', ...createDefaultUser(email, jwtuser.firstname, jwtuser.lastname, 'free') }; const user: User = { id: undefined, customerType: 'buyer', ...createDefaultUser(email, jwtuser.firstname, jwtuser.lastname, null) };
const u = await this.saveUser(user); const u = await this.saveUser(user);
return convertDrizzleUserToUser(u); return convertDrizzleUserToUser(u);
} else { } else {
@ -147,6 +147,7 @@ export class UserService {
throw error; throw error;
} }
} }
async getStates(): Promise<any[]> { async getStates(): Promise<any[]> {
const query = sql`SELECT jsonb_array_elements(${schema.users.areasServed}) ->> 'state' AS state, COUNT(DISTINCT ${schema.users.id}) AS count FROM ${schema.users} GROUP BY state ORDER BY count DESC`; const query = sql`SELECT jsonb_array_elements(${schema.users.areasServed}) ->> 'state' AS state, COUNT(DISTINCT ${schema.users.id}) AS count FROM ${schema.users} GROUP BY state ORDER BY count DESC`;
const result = await this.conn.execute(query); const result = await this.conn.execute(query);

View File

@ -12,6 +12,7 @@ import { HomeComponent } from './pages/home/home.component';
import { BrokerListingsComponent } from './pages/listings/broker-listings/broker-listings.component'; import { BrokerListingsComponent } from './pages/listings/broker-listings/broker-listings.component';
import { BusinessListingsComponent } from './pages/listings/business-listings/business-listings.component'; import { BusinessListingsComponent } from './pages/listings/business-listings/business-listings.component';
import { CommercialPropertyListingsComponent } from './pages/listings/commercial-property-listings/commercial-property-listings.component'; import { CommercialPropertyListingsComponent } from './pages/listings/commercial-property-listings/commercial-property-listings.component';
import { LoginComponent } from './pages/login/login.component';
import { PricingComponent } from './pages/pricing/pricing.component'; import { PricingComponent } from './pages/pricing/pricing.component';
import { AccountComponent } from './pages/subscription/account/account.component'; import { AccountComponent } from './pages/subscription/account/account.component';
import { EditBusinessListingComponent } from './pages/subscription/edit-business-listing/edit-business-listing.component'; import { EditBusinessListingComponent } from './pages/subscription/edit-business-listing/edit-business-listing.component';
@ -56,6 +57,10 @@ export const routes: Routes = [
canActivate: [ListingCategoryGuard], canActivate: [ListingCategoryGuard],
component: NotFoundComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet component: NotFoundComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
}, },
{
path: 'login/:page',
component: LoginComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
},
{ {
path: 'notfound', path: 'notfound',
component: NotFoundComponent, component: NotFoundComponent,

View File

@ -98,7 +98,7 @@ export class HeaderComponent {
} }
login() { login() {
this.keycloakService.login({ this.keycloakService.login({
redirectUri: window.location.href, redirectUri: `${window.location.origin}/login${this.router.routerState.snapshot.url}`,
}); });
} }
register() { register() {

View File

@ -84,7 +84,7 @@ export class HomeComponent {
} }
login() { login() {
this.keycloakService.login({ this.keycloakService.login({
redirectUri: window.location.href, redirectUri: `${window.location.origin}/login${this.router.routerState.snapshot.url}`,
}); });
} }
register() { register() {

View File

@ -0,0 +1,38 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { KeycloakService } from 'keycloak-angular';
import { lastValueFrom } from 'rxjs';
import { SubscriptionsService } from '../../services/subscriptions.service';
import { UserService } from '../../services/user.service';
import { map2User } from '../../utils/utils';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, RouterModule],
template: ``,
})
export class LoginComponent {
page: string | undefined = this.activatedRoute.snapshot.params['page'] as string | undefined;
constructor(public userService: UserService, private activatedRoute: ActivatedRoute, private keycloakService: KeycloakService, private router: Router, private subscriptionService: SubscriptionsService) {}
async ngOnInit() {
const token = await this.keycloakService.getToken();
const keycloakUser = map2User(token);
const email = keycloakUser.email;
const user = await this.userService.getByMail(email);
if (!user.subscriptionPlan) {
//this.router.navigate(['/pricing']);
const subscriptions = await lastValueFrom(this.subscriptionService.getAllSubscriptions(user.email));
const activeSubscription = subscriptions.filter(s => s.status === 'active');
if (activeSubscription.length > 0) {
user.subscriptionPlan = activeSubscription[0].metadata['plan'] === 'Broker Plan' ? 'broker' : 'professional';
this.userService.save(user);
} else {
this.router.navigate([`/pricing`]);
return;
}
}
this.router.navigate([`/${this.page}`]);
}
}

View File

@ -1,11 +1,14 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { KeycloakService } from 'keycloak-angular'; import { KeycloakService } from 'keycloak-angular';
import { StripeService } from 'ngx-stripe'; import { StripeService } from 'ngx-stripe';
import { switchMap } from 'rxjs'; import { switchMap } from 'rxjs';
import { Checkout, KeycloakUser } from '../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { UserService } from '../../services/user.service';
import { SharedModule } from '../../shared/shared/shared.module'; import { SharedModule } from '../../shared/shared/shared.module';
import { map2User } from '../../utils/utils';
@Component({ @Component({
selector: 'app-pricing', selector: 'app-pricing',
@ -17,26 +20,40 @@ import { SharedModule } from '../../shared/shared/shared.module';
export class PricingComponent { export class PricingComponent {
private apiBaseUrl = environment.apiBaseUrl; private apiBaseUrl = environment.apiBaseUrl;
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined; private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
constructor(public keycloakService: KeycloakService, private http: HttpClient, private stripeService: StripeService, private activatedRoute: ActivatedRoute) {} keycloakUser: KeycloakUser;
constructor(public keycloakService: KeycloakService, private http: HttpClient, private stripeService: StripeService, private activatedRoute: ActivatedRoute, private userService: UserService, private router: Router) {}
ngOnInit() { async ngOnInit() {
const token = await this.keycloakService.getToken();
this.keycloakUser = map2User(token);
if (this.id) { if (this.id) {
this.checkout(atob(this.id)); this.checkout({ priceId: atob(this.id), email: this.keycloakUser.email, name: `${this.keycloakUser.firstName} ${this.keycloakUser.lastName}` });
} }
} }
register(priceId?: string) { async register(priceId?: string) {
if (this.keycloakUser) {
if (!priceId) {
const user = await this.userService.getByMail(this.keycloakUser.email);
user.subscriptionPlan = 'free';
await this.userService.save(user);
this.router.navigate([`/account`]);
} else {
this.checkout({ priceId: atob(this.id), email: this.keycloakUser.email, name: `${this.keycloakUser.firstName} ${this.keycloakUser.lastName}` });
}
} else {
if (priceId) { if (priceId) {
this.keycloakService.register({ redirectUri: `${window.location.origin}/pricing/${btoa(priceId)}` }); this.keycloakService.register({ redirectUri: `${window.location.origin}/pricing/${btoa(priceId)}` });
} else { } else {
this.keycloakService.register({ redirectUri: `${window.location.origin}/account` }); this.keycloakService.register({ redirectUri: `${window.location.origin}/account` });
} }
} }
}
checkout(priceId) { checkout(checkout: Checkout) {
// Check the server.js tab to see an example implementation // Check the server.js tab to see an example implementation
this.http this.http
.post(`${this.apiBaseUrl}/bizmatch/payment/create-checkout-session`, { priceId }) .post(`${this.apiBaseUrl}/bizmatch/payment/create-checkout-session`, checkout)
.pipe( .pipe(
switchMap((session: any) => { switchMap((session: any) => {
return this.stripeService.redirectToCheckout({ sessionId: session.id }); return this.stripeService.redirectToCheckout({ sessionId: session.id });

View File

@ -220,46 +220,42 @@
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Level</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Level</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Start Date</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Start Date</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Modified</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">End Date</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">End Date</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Next Settlement</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
@for (subscription of subscriptions; track subscriptions; let i=$index){
<tr> <tr>
@for (subscription of userSubscriptions; track userSubscriptions){ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ getLevel(i) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ subscription.id }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ getStartDate(i) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ subscription.level }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ getEndDate(i) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ subscription.start | date }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ getNextSettlement(i) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ subscription.modified | date }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ getStatus(i) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ subscription.end | date }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ subscription.status }}</td>
}
</tr> </tr>
}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="flex justify-start">
<div class="mt-8 sm:hidden"> <button routerLink="/pricing" class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">Upgrade Subscription Plan</button>
</div>
<!-- <div class="mt-8 sm:hidden">
<h3 class="text-lg font-medium text-gray-700 mb-1">Membership Level</h3> <h3 class="text-lg font-medium text-gray-700 mb-1">Membership Level</h3>
<div class="space-y-2"> <div class="space-y-2">
@for (subscription of userSubscriptions; track userSubscriptions){ @for (subscription of userSubscriptions; track userSubscriptions){
<div class="bg-white shadow overflow-hidden sm:rounded-lg"> <div class="bg-white shadow overflow-hidden sm:rounded-lg">
<div class="px-4 py-5 sm:px-6"> <div class="px-4 py-5 sm:px-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-2 sm:grid-cols-2"> <dl class="grid grid-cols-1 gap-x-4 gap-y-2 sm:grid-cols-2">
<div class="sm:col-span-1 flex">
<dt class="text-sm font-bold text-gray-500 mr-2">ID</dt>
<dd class="text-sm text-gray-900">{{ subscription.id }}</dd>
</div>
<div class="sm:col-span-1 flex"> <div class="sm:col-span-1 flex">
<dt class="text-sm font-bold text-gray-500 mr-2">Level</dt> <dt class="text-sm font-bold text-gray-500 mr-2">Level</dt>
<dd class="text-sm text-gray-900">{{ subscription.level }}</dd> <dd class="text-sm text-gray-900">{{ level }}</dd>
</div> </div>
<div class="sm:col-span-1 flex"> <div class="sm:col-span-1 flex">
<dt class="text-sm font-bold text-gray-500 mr-2">Start Date</dt> <dt class="text-sm font-bold text-gray-500 mr-2">Start Date</dt>
@ -282,7 +278,7 @@
</div> </div>
} }
</div> </div>
</div> </div> -->
</div> </div>
} }
</div> </div>

View File

@ -1,4 +1,4 @@
import { TitleCasePipe } from '@angular/common'; import { DatePipe, TitleCasePipe } from '@angular/common';
import { ChangeDetectorRef, Component } from '@angular/core'; import { ChangeDetectorRef, Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { faTrash } from '@fortawesome/free-solid-svg-icons'; import { faTrash } from '@fortawesome/free-solid-svg-icons';
@ -10,7 +10,7 @@ import { ImageCropperComponent } from 'ngx-image-cropper';
import { QuillModule } from 'ngx-quill'; import { QuillModule } from 'ngx-quill';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { User } from '../../../../../../bizmatch-server/src/models/db.model'; import { User } from '../../../../../../bizmatch-server/src/models/db.model';
import { AutoCompleteCompleteEvent, Invoice, Subscription, UploadParams, ValidationMessage, createDefaultUser, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model'; import { AutoCompleteCompleteEvent, Invoice, StripeSubscription, UploadParams, ValidationMessage, createDefaultUser, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component'; import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
import { ConfirmationService } from '../../../components/confirmation/confirmation.service'; import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
@ -53,15 +53,13 @@ import { TOOLBAR_OPTIONS } from '../../utils/defaults';
TooltipComponent, TooltipComponent,
ValidatedCountyComponent, ValidatedCountyComponent,
], ],
providers: [TitleCasePipe], providers: [TitleCasePipe, DatePipe],
templateUrl: './account.component.html', templateUrl: './account.component.html',
styleUrl: './account.component.scss', styleUrl: './account.component.scss',
}) })
export class AccountComponent { export class AccountComponent {
id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined; id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
user: User; user: User;
subscriptions: Array<Subscription>;
userSubscriptions: Array<Subscription> = [];
companyLogoUrl: string; companyLogoUrl: string;
profileUrl: string; profileUrl: string;
type: 'company' | 'profile'; type: 'company' | 'profile';
@ -79,9 +77,9 @@ export class AccountComponent {
customerTypeOptions: Array<{ value: string; label: string }> = []; customerTypeOptions: Array<{ value: string; label: string }> = [];
customerSubTypeOptions: Array<{ value: string; label: string }> = []; customerSubTypeOptions: Array<{ value: string; label: string }> = [];
tooltipTarget = 'tooltip-areasServed'; tooltipTarget = 'tooltip-areasServed';
subscriptions: StripeSubscription[] | any[];
constructor( constructor(
public userService: UserService, public userService: UserService,
private subscriptionService: SubscriptionsService,
private geoService: GeoService, private geoService: GeoService,
public selectOptions: SelectOptionsService, public selectOptions: SelectOptionsService,
private cdref: ChangeDetectorRef, private cdref: ChangeDetectorRef,
@ -95,6 +93,8 @@ export class AccountComponent {
private sharedService: SharedService, private sharedService: SharedService,
private titleCasePipe: TitleCasePipe, private titleCasePipe: TitleCasePipe,
private validationMessagesService: ValidationMessagesService, private validationMessagesService: ValidationMessagesService,
private subscriptionService: SubscriptionsService,
private datePipe: DatePipe,
) {} ) {}
async ngOnInit() { async ngOnInit() {
setTimeout(() => { setTimeout(() => {
@ -109,7 +109,10 @@ export class AccountComponent {
this.user = await this.userService.getByMail(email); this.user = await this.userService.getByMail(email);
} }
this.userSubscriptions = await lastValueFrom(this.subscriptionService.getAllSubscriptions(this.user.id)); this.subscriptions = await lastValueFrom(this.subscriptionService.getAllSubscriptions(this.user.email));
if (this.subscriptions.length === 0) {
this.subscriptions = [{ ended_at: null, start_date: Math.floor(new Date(this.user.created).getTime() / 1000), status: null, metadata: { plan: 'Free Plan' } }];
}
this.profileUrl = this.user.hasProfile ? `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}` : `/assets/images/placeholder.png`; this.profileUrl = this.user.hasProfile ? `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}` : `/assets/images/placeholder.png`;
this.companyLogoUrl = this.user.hasCompanyLogo ? `${this.env.imageBaseUrl}/pictures/logo/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}` : `/assets/images/placeholder.png`; this.companyLogoUrl = this.user.hasCompanyLogo ? `${this.env.imageBaseUrl}/pictures/logo/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}` : `/assets/images/placeholder.png`;
@ -133,7 +136,7 @@ export class AccountComponent {
const confirmed = await this.confirmationService.showConfirmation({ message: 'Are you sure you want to switch to Buyer ? All your listings as well as all your professionals informations will be deleted' }); const confirmed = await this.confirmationService.showConfirmation({ message: 'Are you sure you want to switch to Buyer ? All your listings as well as all your professionals informations will be deleted' });
if (confirmed) { if (confirmed) {
const id = this.user.id; const id = this.user.id;
this.user = createDefaultUser(this.user.email, this.user.firstname, this.user.lastname, 'free'); this.user = createDefaultUser(this.user.email, this.user.firstname, this.user.lastname, null);
this.user.customerType = 'buyer'; this.user.customerType = 'buyer';
this.user.id = id; this.user.id = id;
this.imageService.deleteLogoImagesByMail(this.user.email); this.imageService.deleteLogoImagesByMail(this.user.email);
@ -244,4 +247,19 @@ export class AccountComponent {
this.user.areasServed[index].county = null; this.user.areasServed[index].county = null;
} }
} }
getLevel(i: number) {
return this.subscriptions[i].metadata.plan;
}
getStartDate(i: number) {
return this.datePipe.transform(new Date(this.subscriptions[i].start_date * 1000));
}
getEndDate(i: number) {
return this.subscriptions[i].status === 'trialing' ? this.datePipe.transform(new Date(this.subscriptions[i].current_period_end * 1000)) : '---';
}
getNextSettlement(i: number) {
return this.subscriptions[i].status === 'active' ? this.datePipe.transform(new Date(this.subscriptions[i].current_period_end * 1000)) : '---';
}
getStatus(i: number) {
return this.subscriptions[i].status ? this.subscriptions[i].status : '';
}
} }

View File

@ -1,7 +1,7 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { Subscription } from '../../../../bizmatch-server/src/models/main.model'; import { StripeSubscription } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
@Injectable({ @Injectable({
@ -11,7 +11,7 @@ export class SubscriptionsService {
private apiBaseUrl = environment.apiBaseUrl; private apiBaseUrl = environment.apiBaseUrl;
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
getAllSubscriptions(id: string): Observable<Subscription[]> { getAllSubscriptions(email: string): Observable<StripeSubscription[]> {
return this.http.get<Subscription[]>(`${this.apiBaseUrl}/bizmatch/user/subscriptions/${id}`); return this.http.get<StripeSubscription[]>(`${this.apiBaseUrl}/bizmatch/payment/subscriptions/${email}`);
} }
} }