Mail Modul überarbeitet, Korrekturen am PaymentService, neuer customerType seller

This commit is contained in:
Andreas Knuth 2024-08-22 22:59:33 +02:00
parent b4609d07ba
commit 7a286e3519
16 changed files with 183 additions and 76 deletions

View File

@ -14,7 +14,8 @@
"console": "integratedTerminal", "console": "integratedTerminal",
"env": { "env": {
"HOST_NAME": "localhost" "HOST_NAME": "localhost"
} },
"preLaunchTask": "Start Stripe Listener"
}, },
{ {
"type": "node", "type": "node",
@ -60,5 +61,30 @@
"sourceMaps": true, "sourceMaps": true,
"smartStep": true "smartStep": true
} }
],
"tasks": [
{
"label": "Start Stripe Listener",
"type": "shell",
"command": "stripe listen -e checkout.session.completed --forward-to http://localhost:3000/bizmatch/payment/webhook",
"isBackground": true,
"problemMatcher": [
{
"pattern": [
{
"regexp": ".",
"file": 1,
"location": 2,
"message": 3
}
],
"background": {
"activeOnStart": true,
"beginsPattern": ".",
"endsPattern": "."
}
}
]
}
] ]
} }

31
bizmatch-server/.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,31 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Start Stripe Listener",
"type": "shell",
"command": "stripe listen -e checkout.session.completed --forward-to http://localhost:3000/bizmatch/payment/webhook",
"problemMatcher": [],
"isBackground": true, // Task läuft im Hintergrund
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
}
},
{
"label": "Start Nest.js",
"type": "npm",
"script": "start:debug",
"isBackground": false,
"problemMatcher": [],
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
}
}
]
}

View File

@ -2,7 +2,7 @@ import { boolean, char, doublePrecision, integer, jsonb, pgEnum, pgTable, serial
import { AreasServed, LicensedIn } from '../models/db.model'; import { AreasServed, LicensedIn } from '../models/db.model';
export const PG_CONNECTION = 'PG_CONNECTION'; export const PG_CONNECTION = 'PG_CONNECTION';
export const genderEnum = pgEnum('gender', ['male', 'female']); export const genderEnum = pgEnum('gender', ['male', 'female']);
export const customerTypeEnum = pgEnum('customerType', ['buyer', 'professional']); export const customerTypeEnum = pgEnum('customerType', ['buyer', 'seller', 'professional']);
export const customerSubTypeEnum = pgEnum('customerSubType', ['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']); export const customerSubTypeEnum = pgEnum('customerSubType', ['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
export const listingsCategoryEnum = pgEnum('listingsCategory', ['commercialProperty', 'business']); export const listingsCategoryEnum = pgEnum('listingsCategory', ['commercialProperty', 'business']);
export const subscriptionTypeEnum = pgEnum('subscriptionType', ['free', 'professional', 'broker']); export const subscriptionTypeEnum = pgEnum('subscriptionType', ['free', 'professional', 'broker']);
@ -33,8 +33,6 @@ export const users = pgTable('users', {
longitude: doublePrecision('longitude'), longitude: doublePrecision('longitude'),
stripeCustomerId: text('stripeCustomerId'), stripeCustomerId: text('stripeCustomerId'),
subscriptionId: text('subscriptionId'), subscriptionId: text('subscriptionId'),
planActive: boolean('planActive').default(false),
planExpires: timestamp('planExpires'),
subscriptionPlan: subscriptionTypeEnum('subscriptionType'), subscriptionPlan: subscriptionTypeEnum('subscriptionType'),
// embedding: vector('embedding', { dimensions: 1536 }), // embedding: vector('embedding', { dimensions: 1536 }),
}); });

View File

@ -0,0 +1,14 @@
import { registerAs } from '@nestjs/config';
export default registerAs('mail', () => ({
host: 'email-smtp.us-east-2.amazonaws.com',
port: 587,
secure: false,
auth: {
user: process.env.AMAZON_USER,
pass: process.env.AMAZON_PASSWORD,
},
defaults: {
from: '"No Reply" <noreply@example.com>',
},
}));

View File

@ -1,4 +1,5 @@
import { Body, Controller, Post } from '@nestjs/common'; import { Body, Controller, Post } from '@nestjs/common';
import { User } from 'src/models/db.model';
import { ErrorResponse, MailInfo } from '../models/main.model'; import { ErrorResponse, MailInfo } from '../models/main.model';
import { MailService } from './mail.service.js'; import { MailService } from './mail.service.js';
@ -13,4 +14,8 @@ export class MailController {
return this.mailService.sendRequest(mailInfo); return this.mailService.sendRequest(mailInfo);
} }
} }
@Post('subscriptionConfirmation')
sendSubscriptionConfirmation(@Body() user: User): Promise<void | ErrorResponse> {
return this.mailService.sendSubscriptionConfirmation(user);
}
} }

View File

@ -13,33 +13,67 @@ import { MailController } from './mail.controller.js';
import { MailService } from './mail.service.js'; import { MailService } from './mail.service.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const user = process.env.amazon_user; const user = process.env.AMAZON_USER;
const password = process.env.amazon_password; const password = process.env.AMAZON_PASSWORD;
console.log('Amazon User:', process.env.AMAZON_USER);
console.log('Amazon Password:', process.env.AMAZON_PASSWORD);
@Module({ @Module({
imports: [ imports: [
DrizzleModule, DrizzleModule,
UserModule, UserModule,
GeoModule, GeoModule,
MailerModule.forRoot({ // ConfigModule.forFeature(mailConfig),
transport: { // MailerModule.forRoot({
host: 'email-smtp.us-east-2.amazonaws.com', // transport: {
secure: false, // host: 'email-smtp.us-east-2.amazonaws.com',
port: 587, // secure: false,
auth: { // port: 587,
user: 'AKIAU6GDWVAQ2QNFLNWN', // auth: {
pass: 'BDE9nZv/ARbpotim1mIOir52WgIbpSi9cv1oJoH8oEf7', // user: user, //'AKIAU6GDWVAQ2QNFLNWN',
// pass: password, //'BDE9nZv/ARbpotim1mIOir52WgIbpSi9cv1oJoH8oEf7',
// },
// },
// defaults: {
// from: '"No Reply" <noreply@example.com>',
// },
// template: {
// dir: join(__dirname, 'templates'),
// adapter: new HandlebarsAdapter({
// eq: function (a, b) {
// return a === b;
// },
// }),
// options: {
// strict: true,
// },
// },
// }),
MailerModule.forRootAsync({
useFactory: () => ({
transport: {
host: 'email-smtp.us-east-2.amazonaws.com',
secure: false,
port: 587,
auth: {
user: process.env.AMAZON_USER, //'AKIAU6GDWVAQ2QNFLNWN',
pass: process.env.AMAZON_PASSWORD, //'BDE9nZv/ARbpotim1mIOir52WgIbpSi9cv1oJoH8oEf7',
},
}, },
}, defaults: {
defaults: { from: '"No Reply" <noreply@example.com>',
from: '"No Reply" <noreply@example.com>',
},
template: {
dir: join(__dirname, 'templates'),
adapter: new HandlebarsAdapter(), // or new PugAdapter() or new EjsAdapter()
options: {
strict: true,
}, },
}, template: {
dir: join(__dirname, 'templates'),
adapter: new HandlebarsAdapter({
eq: function (a, b) {
return a === b;
},
}),
options: {
strict: true,
},
},
}),
}), }),
], ],
providers: [MailService, UserService, FileService, GeoService], providers: [MailService, UserService, FileService, GeoService],

View File

@ -83,7 +83,7 @@ export class MailService {
} }
async sendSubscriptionConfirmation(user: User): Promise<void> { async sendSubscriptionConfirmation(user: User): Promise<void> {
await this.mailerService.sendMail({ await this.mailerService.sendMail({
to: 'support@bizmatch.net', to: user.email,
from: `"Bizmatch Support Team" <info@bizmatch.net>`, from: `"Bizmatch Support Team" <info@bizmatch.net>`,
subject: `Subscription Confirmation`, subject: `Subscription Confirmation`,
//template: './inquiry', // `.hbs` extension is appended automatically //template: './inquiry', // `.hbs` extension is appended automatically

View File

@ -17,18 +17,18 @@ export interface UserData {
hasCompanyLogo?: boolean; hasCompanyLogo?: boolean;
licensedIn?: string[]; licensedIn?: string[];
gender?: 'male' | 'female'; gender?: 'male' | 'female';
customerType?: 'buyer' | 'broker' | 'professional'; customerType?: 'buyer' | 'seller' | 'professional';
customerSubType?: 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser'; customerSubType?: 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser';
created?: Date; created?: Date;
updated?: Date; updated?: Date;
} }
export type Gender = 'male' | 'female'; export type Gender = 'male' | 'female';
export type CustomerType = 'buyer' | 'professional'; export type CustomerType = 'buyer' | 'seller' | 'professional';
export type CustomerSubType = 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser'; export type CustomerSubType = 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser';
export type ListingsCategory = 'commercialProperty' | 'business'; export type ListingsCategory = 'commercialProperty' | 'business';
export const GenderEnum = z.enum(['male', 'female']); export const GenderEnum = z.enum(['male', 'female']);
export const CustomerTypeEnum = z.enum(['buyer', 'professional']); export const CustomerTypeEnum = z.enum(['buyer', 'seller', 'professional']);
export const SubscriptionTypeEnum = z.enum(['free', 'professional', 'broker']); export const SubscriptionTypeEnum = z.enum(['free', 'professional', 'broker']);
export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']); export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']); export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']);

View File

@ -58,23 +58,19 @@ export class PaymentService {
quantity: 1, quantity: 1,
}, },
], ],
success_url: 'http://localhost:4200/success', success_url: `${process.env.WEB_HOST}/success`,
cancel_url: 'http://localhost:4200/pricing', cancel_url: `${process.env.WEB_HOST}/pricing`,
// customer_email: checkout.email, // customer_email: checkout.email,
customer: customerId, 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: btoa(checkout.name),
client_reference_id: '1234',
locale: 'en', locale: 'en',
subscription_data: { subscription_data: {
trial_end: Math.floor(new Date().setMonth(new Date().getMonth() + 3) / 1000), trial_end: Math.floor(new Date().setMonth(new Date().getMonth() + 3) / 1000),
metadata: { plan: product.name },
}, },
metadata: { plan: product.name },
}); });
return session; return session;
} else { } else {
@ -100,20 +96,11 @@ export class PaymentService {
}); });
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.customerType = 'professional';
//session.shipping_details -> if (session.metadata['plan'] === 'Broker Plan') {
// "shipping_details": { user.customerSubType = 'broker';
// "address": { }
// "city": "Springfield", user.subscriptionPlan = session.metadata['plan'] === 'Broker Plan' ? 'broker' : 'professional'; //session.metadata['subscriptionPlan'] as 'free' | 'professional' | 'broker';
// "country": "US",
// "line1": "West Maple Avenue South",
// "line2": null,
// "postal_code": "62704",
// "state": "IL"
// },
// "name": "Johnathan Miller"
// }
user.subscriptionPlan = session.amount_total === 4900 ? 'broker' : 'professional'; //session.metadata['subscriptionPlan'] as 'free' | 'professional' | 'broker';
this.userService.saveUser(user); this.userService.saveUser(user);
this.mailService.sendSubscriptionConfirmation(user); this.mailService.sendSubscriptionConfirmation(user);
} }

View File

@ -52,6 +52,7 @@ export class SelectOptionsService {
]; ];
public customerTypes: Array<KeyValue> = [ public customerTypes: Array<KeyValue> = [
{ name: 'Buyer', value: 'buyer' }, { name: 'Buyer', value: 'buyer' },
{ name: 'Commercial Property Seller', value: 'seller' },
{ name: 'Professional', value: 'professional' }, { name: 'Professional', value: 'professional' },
]; ];
public customerSubTypes: Array<KeyValue> = [ public customerSubTypes: Array<KeyValue> = [

View File

@ -11,7 +11,15 @@
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage"></app-tooltip> <app-tooltip id="tooltip-{{ name }}" [text]="validationMessage"></app-tooltip>
} }
</label> </label>
<select [id]="name" [name]="name" [ngModel]="value" (change)="onSelectChange($event)" (blur)="onTouched()" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"> <select
[disabled]="disabled"
[id]="name"
[name]="name"
[ngModel]="value"
(change)="onSelectChange($event)"
(blur)="onTouched()"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="" disabled selected>Select an option</option> <option value="" disabled selected>Select an option</option>
<option *ngFor="let option of options" [value]="option.value"> <option *ngFor="let option of options" [value]="option.value">
{{ option.label }} {{ option.label }}

View File

@ -20,6 +20,7 @@ import { ValidationMessagesService } from '../validation-messages.service';
}) })
export class ValidatedSelectComponent extends BaseInputComponent { export class ValidatedSelectComponent extends BaseInputComponent {
@Input() options: Array<{ value: any; label: string }> = []; @Input() options: Array<{ value: any; label: string }> = [];
@Input() disabled = false;
@Output() valueChange = new EventEmitter<any>(); @Output() valueChange = new EventEmitter<any>();
constructor(validationMessagesService: ValidationMessagesService) { constructor(validationMessagesService: ValidationMessagesService) {

View File

@ -39,7 +39,7 @@ export class PricingComponent {
await this.userService.save(user); await this.userService.save(user);
this.router.navigate([`/account`]); this.router.navigate([`/account`]);
} else { } else {
this.checkout({ priceId: atob(this.id), email: this.keycloakUser.email, name: `${this.keycloakUser.firstName} ${this.keycloakUser.lastName}` }); this.checkout({ priceId: priceId, email: this.keycloakUser.email, name: `${this.keycloakUser.firstName} ${this.keycloakUser.lastName}` });
} }
} else { } else {
if (priceId) { if (priceId) {

View File

@ -78,7 +78,7 @@
</div> </div>
}@else{ }@else{
<app-validated-select label="Customer Type" name="customerType" [(ngModel)]="user.customerType" [options]="customerTypeOptions"></app-validated-select> <app-validated-select [disabled]="user.customerType === 'professional'" label="Customer Type" name="customerType" [(ngModel)]="user.customerType" [options]="customerTypeOptions"></app-validated-select>
} @if (isProfessional){ } @if (isProfessional){
<!-- <div> <!-- <div>
<label for="customerSubType" class="block text-sm font-medium text-gray-700">Professional Type</label> <label for="customerSubType" class="block text-sm font-medium text-gray-700">Professional Type</label>
@ -86,7 +86,7 @@
<option *ngFor="let subType of customerSubTypes" [value]="subType">{{ subType | titlecase }}</option> <option *ngFor="let subType of customerSubTypes" [value]="subType">{{ subType | titlecase }}</option>
</select> </select>
</div> --> </div> -->
<app-validated-select label="Professional Type" name="customerSubType" [(ngModel)]="user.customerSubType" [options]="customerSubTypeOptions"></app-validated-select> <app-validated-select [disabled]="user.customerSubType === 'broker'" label="Professional Type" name="customerSubType" [(ngModel)]="user.customerSubType" [options]="customerSubTypeOptions"></app-validated-select>
} }
</div> </div>
@if (isProfessional){ @if (isProfessional){
@ -246,39 +246,39 @@
<div class="flex justify-start"> <div class="flex justify-start">
<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> <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>
<!-- <div class="mt-8 sm:hidden"> <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 subscriptions; track subscriptions; let i=$index){
<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"> <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">{{ level }}</dd> <dd class="text-sm text-gray-900">{{ getLevel(i) }}</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>
<dd class="text-sm text-gray-900">{{ subscription.start | date }}</dd> <dd class="text-sm text-gray-900">{{ getStartDate(i) }}</dd>
</div>
<div class="sm:col-span-1 flex">
<dt class="text-sm font-bold text-gray-500 mr-2">Date Modified</dt>
<dd class="text-sm text-gray-900">{{ subscription.modified | date }}</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">End Date</dt> <dt class="text-sm font-bold text-gray-500 mr-2">End Date</dt>
<dd class="text-sm text-gray-900">{{ subscription.end | date }}</dd> <dd class="text-sm text-gray-900">{{ getEndDate(i) }}</dd>
</div>
<div class="sm:col-span-1 flex">
<dt class="text-sm font-bold text-gray-500 mr-2">Next Settlement</dt>
<dd class="text-sm text-gray-900">{{ getNextSettlement(i) }}</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">Status</dt> <dt class="text-sm font-bold text-gray-500 mr-2">Status</dt>
<dd class="text-sm text-gray-900">{{ subscription.status }}</dd> <dd class="text-sm text-gray-900">{{ getStatus(i) }}</dd>
</div> </div>
</dl> </dl>
</div> </div>
</div> </div>
} }
</div> </div>
</div> --> </div>
</div> </div>
} }
</div> </div>

View File

@ -67,8 +67,6 @@ export class AccountComponent {
editorModules = TOOLBAR_OPTIONS; editorModules = TOOLBAR_OPTIONS;
env = environment; env = environment;
faTrash = faTrash; faTrash = faTrash;
customerTypes = ['buyer', 'professional'];
customerSubTypes = ['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser'];
quillModules = { quillModules = {
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']], toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
}; };
@ -116,15 +114,19 @@ export class AccountComponent {
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`;
this.customerTypeOptions = this.customerTypes.map(type => ({ this.customerTypeOptions = this.selectOptions.customerTypes
value: type, .filter(ct => ct.value === 'buyer' || ct.value === 'seller' || this.user.customerType === 'professional')
label: this.titleCasePipe.transform(type), .map(type => ({
})); value: type.value,
label: this.titleCasePipe.transform(type.name),
}));
this.customerSubTypeOptions = this.customerSubTypes.map(type => ({ this.customerSubTypeOptions = this.selectOptions.customerSubTypes
value: type, .filter(ct => ct.value !== 'broker' || this.user.customerSubType === 'broker')
label: this.titleCasePipe.transform(type), .map(type => ({
})); value: type.value,
label: this.titleCasePipe.transform(type.name),
}));
} }
ngOnDestroy() { ngOnDestroy() {
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten

View File

@ -37,7 +37,7 @@ export class SuccessComponent {
} }
let attempts = 0; let attempts = 0;
const maxAttempts = 5; const maxAttempts = 20;
const interval = 5000; // 5 Sekunden const interval = 5000; // 5 Sekunden
const intervalId = setInterval(async () => { const intervalId = setInterval(async () => {