Skip to content

Commit 48a6b7d

Browse files
authored
Merge pull request #62 from topcoder-platform/PM-1263_notify-users-payment-setup
PM-1263 - notify users via email about payment setup required
2 parents af99bf6 + 24f0d33 commit 48a6b7d

29 files changed

+9869
-33
lines changed

package-lock.json

Lines changed: 9505 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"csv": "^6.3.11",
3333
"csv-stringify": "^6.5.2",
3434
"dotenv": "^16.5.0",
35+
"json-stringify-safe": "^5.0.1",
3536
"jsonwebtoken": "^9.0.2",
3637
"jwks-rsa": "^3.2.0",
3738
"lodash": "^4.17.21",
@@ -50,6 +51,7 @@
5051
"@swc/core": "^1.10.7",
5152
"@types/express": "^5.0.0",
5253
"@types/jest": "^29.5.14",
54+
"@types/json-stringify-safe": "^5.0.3",
5355
"@types/lodash": "^4.17.16",
5456
"@types/node": "^22.13.1",
5557
"@types/supertest": "^6.0.2",

src/api/admin/admin.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
HttpStatus,
44
NotFoundException,
55
BadRequestException,
6-
Logger,
76
} from '@nestjs/common';
87

98
import { Prisma } from '@prisma/client';
@@ -18,6 +17,7 @@ import {
1817
AdminPaymentUpdateData,
1918
TopcoderChallengesService,
2019
} from 'src/shared/topcoder/challenges.service';
20+
import { Logger } from 'src/shared/global';
2121

2222
/**
2323
* The admin winning service.

src/api/health-check/healthCheck.controller.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
import {
2-
Controller,
3-
Get,
4-
Logger,
5-
Version,
6-
VERSION_NEUTRAL,
7-
} from '@nestjs/common';
1+
import { Controller, Get, Version, VERSION_NEUTRAL } from '@nestjs/common';
82
import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
93
import { Public } from 'src/core/auth/decorators';
104
import { PrismaService } from 'src/shared/global/prisma.service';
5+
import { Logger } from 'src/shared/global';
116

127
export enum HealthCheckStatus {
138
healthy = 'healthy',

src/api/repository/winnings.repo.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
1+
import { HttpStatus, Injectable } from '@nestjs/common';
22
import {
33
payment_status,
44
Prisma,
@@ -15,6 +15,7 @@ import {
1515
WinningsCategory,
1616
} from 'src/dto/winning.dto';
1717
import { PrismaService } from 'src/shared/global/prisma.service';
18+
import { Logger } from 'src/shared/global';
1819

1920
const ONE_DAY = 24 * 60 * 60 * 1000;
2021

src/api/wallet/wallet.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, HttpStatus, Logger } from '@nestjs/common';
1+
import { Injectable, HttpStatus } from '@nestjs/common';
22

33
import { PrismaService } from 'src/shared/global/prisma.service';
44

@@ -8,6 +8,7 @@ import { WinningsType } from 'src/dto/winning.dto';
88
import { TaxFormRepository } from '../repository/taxForm.repo';
99
import { PaymentMethodRepository } from '../repository/paymentMethod.repo';
1010
import { TrolleyService } from 'src/shared/global/trolley.service';
11+
import { Logger } from 'src/shared/global';
1112

1213
/**
1314
* The winning service.

src/api/webhooks/trolley/handlers/payment.handler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, Logger } from '@nestjs/common';
1+
import { Injectable } from '@nestjs/common';
22
import {
33
PaymentProcessedEventData,
44
PaymentProcessedEventStatus,
@@ -9,6 +9,7 @@ import { PaymentsService } from 'src/shared/payments';
99
import { payment_status } from '@prisma/client';
1010
import { PrismaService } from 'src/shared/global/prisma.service';
1111
import { JsonObject } from '@prisma/client/runtime/library';
12+
import { Logger } from 'src/shared/global';
1213

1314
@Injectable()
1415
export class PaymentHandler {

src/api/webhooks/trolley/handlers/recipient-account.handler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, Logger } from '@nestjs/common';
1+
import { Injectable } from '@nestjs/common';
22
import { WebhookEvent } from '../../webhooks.decorators';
33
import { PrismaService } from 'src/shared/global/prisma.service';
44
import {
@@ -8,6 +8,7 @@ import {
88
} from './recipient-account.types';
99
import { payment_method_status } from '@prisma/client';
1010
import { PaymentsService } from 'src/shared/payments';
11+
import { Logger } from 'src/shared/global';
1112

1213
@Injectable()
1314
export class RecipientAccountHandler {

src/api/webhooks/trolley/handlers/tax-form.handler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, Logger } from '@nestjs/common';
1+
import { Injectable } from '@nestjs/common';
22
import { WebhookEvent } from '../../webhooks.decorators';
33
import { PrismaService } from 'src/shared/global/prisma.service';
44
import {
@@ -13,6 +13,7 @@ import {
1313
TaxFormStatusUpdatedEventData,
1414
TaxFormWebhookEvent,
1515
} from './tax-form.types';
16+
import { Logger } from 'src/shared/global';
1617

1718
@Injectable()
1819
export class TaxFormHandler {

src/api/webhooks/trolley/trolley.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import crypto from 'crypto';
2-
import { Inject, Injectable, Logger } from '@nestjs/common';
2+
import { Inject, Injectable } from '@nestjs/common';
33
import { trolley_webhook_log, webhook_status } from '@prisma/client';
44
import { PrismaService } from 'src/shared/global/prisma.service';
55
import { ENV_CONFIG } from 'src/config';
6+
import { Logger } from 'src/shared/global';
67

78
export enum TrolleyHeaders {
89
id = 'x-paymentrails-delivery',

src/api/webhooks/webhooks.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import {
44
Req,
55
RawBodyRequest,
66
ForbiddenException,
7-
Logger,
87
} from '@nestjs/common';
98
import { ApiTags } from '@nestjs/swagger';
109
import { TrolleyHeaders, TrolleyService } from './trolley/trolley.service';
1110
import { Public } from 'src/core/auth/decorators';
11+
import { Logger } from 'src/shared/global';
1212

1313
@Public()
1414
@ApiTags('Webhooks')

src/api/winnings/winnings.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { OriginRepository } from '../repository/origin.repo';
55
import { WinningsRepository } from '../repository/winnings.repo';
66
import { TaxFormRepository } from '../repository/taxForm.repo';
77
import { PaymentMethodRepository } from '../repository/paymentMethod.repo';
8+
import { TopcoderModule } from 'src/shared/topcoder/topcoder.module';
89

910
@Module({
10-
imports: [],
11+
imports: [TopcoderModule],
1112
controllers: [WinningsController],
1213
providers: [
1314
WinningsService,

src/api/winnings/winnings.service.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, HttpStatus, Logger } from '@nestjs/common';
1+
import { Injectable, HttpStatus } from '@nestjs/common';
22
import {
33
Prisma,
44
payment,
@@ -14,6 +14,11 @@ import { PaymentStatus } from 'src/dto/payment.dto';
1414
import { OriginRepository } from '../repository/origin.repo';
1515
import { TaxFormRepository } from '../repository/taxForm.repo';
1616
import { PaymentMethodRepository } from '../repository/paymentMethod.repo';
17+
import { TopcoderMembersService } from 'src/shared/topcoder/members.service';
18+
import { BASIC_MEMBER_FIELDS } from 'src/shared/topcoder';
19+
import { ENV_CONFIG } from 'src/config';
20+
import { Logger } from 'src/shared/global';
21+
import { TopcoderEmailService } from 'src/shared/topcoder/tc-email.service';
1722

1823
/**
1924
* The winning service.
@@ -31,8 +36,55 @@ export class WinningsService {
3136
private readonly taxFormRepo: TaxFormRepository,
3237
private readonly paymentMethodRepo: PaymentMethodRepository,
3338
private readonly originRepo: OriginRepository,
39+
private readonly tcMembersService: TopcoderMembersService,
40+
private readonly tcEmailService: TopcoderEmailService,
3441
) {}
3542

43+
private async sendSetupEmailNotification(userId: string, amount: number) {
44+
this.logger.debug(`Fetching member info for user handle: ${userId}`);
45+
const member = await this.tcMembersService.getMemberInfoByUserId(userId, {
46+
fields: BASIC_MEMBER_FIELDS,
47+
});
48+
49+
if (!member) {
50+
this.logger.warn(
51+
`No member information found for user handle: ${userId}`,
52+
);
53+
return;
54+
}
55+
56+
this.logger.debug(
57+
`Member info retrieved successfully for user handle: ${userId}`,
58+
{ member },
59+
);
60+
61+
this.logger.debug(
62+
`Preparing to send payment setup reminder email to: ${member.email}`,
63+
);
64+
65+
try {
66+
await this.tcEmailService.sendEmail(
67+
member.email,
68+
ENV_CONFIG.SENDGRID_TEMPLATE_ID_PAYMENT_SETUP_NOTIFICATION,
69+
{
70+
data: {
71+
user_name: member.firstName || member.handle || member.lastName,
72+
amount_won: amount,
73+
},
74+
},
75+
);
76+
77+
this.logger.debug(
78+
`Payment setup reminder email sent successfully to: ${member.email}`,
79+
);
80+
} catch (error) {
81+
this.logger.error(
82+
`Failed to send payment setup reminder email to: ${member.email}. Error: ${error.message}`,
83+
error,
84+
);
85+
}
86+
}
87+
3688
private async setPayrollPaymentMethod(userId: string) {
3789
const payrollPaymentMethod = await this.prisma.payment_method.findFirst({
3890
where: {
@@ -86,6 +138,11 @@ export class WinningsService {
86138
): Promise<ResponseDto<string>> {
87139
const result = new ResponseDto<string>();
88140

141+
this.logger.debug(
142+
`Creating winning with payments for user ${userId}`,
143+
body,
144+
);
145+
89146
return this.prisma.$transaction(async (tx) => {
90147
const originId = await this.originRepo.getOriginIdByName(body.origin, tx);
91148

@@ -114,6 +171,8 @@ export class WinningsService {
114171
},
115172
};
116173

174+
this.logger.debug('Constructed winning model', { winningModel });
175+
117176
const payrollPayment = (body.attributes || {})['payroll'] === true;
118177

119178
const hasActiveTaxForm = await this.taxFormRepo.hasActiveTaxForm(
@@ -142,23 +201,51 @@ export class WinningsService {
142201
: PaymentStatus.ON_HOLD;
143202

144203
if (payrollPayment) {
204+
this.logger.debug(
205+
`Payroll payment detected. Setting payment status to PAID for user ${body.winnerId}`,
206+
);
145207
paymentModel.payment_status = PaymentStatus.PAID;
146208
await this.setPayrollPaymentMethod(body.winnerId);
147209
}
148210

149211
winningModel.payment.create.push(paymentModel);
212+
this.logger.debug('Added payment model to winning model', {
213+
paymentModel,
214+
});
150215
}
151-
// use prisma nested writes to avoid foreign key checks
216+
217+
this.logger.debug('Attempting to create winning with nested payments.');
152218
const createdWinning = await this.prisma.winnings.create({
153219
data: winningModel as any,
154220
});
221+
155222
if (!createdWinning) {
223+
this.logger.error('Failed to create winning!');
156224
result.error = {
157225
code: HttpStatus.INTERNAL_SERVER_ERROR,
158226
message: 'Failed to create winning!',
159227
};
228+
} else {
229+
this.logger.debug('Successfully created winning', { createdWinning });
230+
}
231+
232+
if (
233+
!payrollPayment &&
234+
(!hasConnectedPaymentMethod || !hasActiveTaxForm)
235+
) {
236+
const amount = body.details.find(
237+
(d) => d.installmentNumber === 1,
238+
)?.totalAmount;
239+
240+
if (amount) {
241+
this.logger.debug(
242+
`Sending setup email notification for user ${body.winnerId} with amount ${amount}`,
243+
);
244+
void this.sendSetupEmailNotification(body.winnerId, amount);
245+
}
160246
}
161247

248+
this.logger.debug('Transaction completed successfully.');
162249
return result;
163250
});
164251
}

src/api/withdrawal/withdrawal.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, Logger } from '@nestjs/common';
1+
import { Injectable } from '@nestjs/common';
22
import { ENV_CONFIG } from 'src/config';
33
import { PrismaService } from 'src/shared/global/prisma.service';
44
import { TaxFormRepository } from '../repository/taxForm.repo';
@@ -12,6 +12,7 @@ import {
1212
} from 'src/shared/topcoder/challenges.service';
1313
import { TopcoderMembersService } from 'src/shared/topcoder/members.service';
1414
import { MEMBER_FIELDS } from 'src/shared/topcoder';
15+
import { Logger } from 'src/shared/global';
1516

1617
const TROLLEY_MINIMUM_PAYMENT_AMOUNT =
1718
ENV_CONFIG.TROLLEY_MINIMUM_PAYMENT_AMOUNT;

src/config/config.env.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { Transform } from 'class-transformer';
2-
import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator';
2+
import {
3+
IsBoolean,
4+
IsInt,
5+
IsNotEmpty,
6+
IsOptional,
7+
IsString,
8+
} from 'class-validator';
39

410
export class ConfigEnv {
511
@IsString()
@@ -65,4 +71,20 @@ export class ConfigEnv {
6571
return false;
6672
})
6773
ACCEPT_CUSTOM_PAYMENTS_MEMO;
74+
75+
@IsString()
76+
@IsOptional()
77+
TC_EMAIL_NOTIFICATIONS_TOPIC = 'external.action.email';
78+
79+
@IsString()
80+
@IsOptional()
81+
TC_EMAIL_FROM_NAME = 'Topcoder';
82+
83+
@IsString()
84+
@IsNotEmpty()
85+
TC_EMAIL_FROM_EMAIL: string;
86+
87+
@IsString()
88+
SENDGRID_TEMPLATE_ID_PAYMENT_SETUP_NOTIFICATION =
89+
'd-919e01f1314e44439bc90971b55f7db7';
6890
}

src/config/config.loader.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import * as dotenv from 'dotenv';
22
import { plainToInstance } from 'class-transformer';
33
import { validateSync } from 'class-validator';
4-
import { Logger } from '@nestjs/common';
54
import { ConfigEnv } from './config.env';
5+
import { Logger } from 'src/shared/global';
6+
7+
const logger = new Logger('ENV_CONFIG');
68

79
/**
810
* Loads and validates environment variables into a `ConfigEnv` instance.
@@ -31,13 +33,17 @@ function loadAndValidateEnv(): ConfigEnv {
3133
});
3234

3335
if (errors.length > 0) {
34-
const logger = new Logger('Config');
3536
for (const err of errors) {
36-
logger.error(JSON.stringify(err.constraints));
37+
logger.error(
38+
'Invalid or missing environment variables!',
39+
err.constraints,
40+
);
3741
}
3842
throw new Error('Invalid environment variables');
3943
}
4044

45+
logger.debug(`Environment config vars successfully loaded and validated!`);
46+
4147
return env;
4248
}
4349

src/core/auth/jwt.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Logger } from '@nestjs/common';
21
import { decode } from 'jsonwebtoken';
32
import { JwksClient } from 'jwks-rsa';
43
import { ENV_CONFIG } from 'src/config';
4+
import { Logger } from 'src/shared/global';
55

66
const logger = new Logger(`auth/jwks`);
77

@@ -26,7 +26,7 @@ const client = new JwksClient({
2626
export const getSigningKey = (token: string) => {
2727
const tokenHeader = decode(token, { complete: true })?.header;
2828

29-
return new Promise((resolve, reject) => {
29+
return new Promise<string>((resolve, reject) => {
3030
if (!tokenHeader || !tokenHeader.kid) {
3131
logger.error('Invalid token: Missing key ID');
3232
return reject(new Error('Invalid token: Missing key ID'));

0 commit comments

Comments
 (0)