Skip to content

Commit 05902ea

Browse files
authored
Merge pull request #322 from dreamgene/feature/299-monetization-integration
Feature/299 monetization integration
2 parents b4d11a9 + fa5f65a commit 05902ea

File tree

14 files changed

+797
-39
lines changed

14 files changed

+797
-39
lines changed

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { CouponsModule } from './modules/coupons/coupons.module';
3434
import { PerksModule } from './modules/perks/perks.module';
3535
import { PerksBoostsModule } from './modules/perks-boosts/perks-boosts.module';
3636
import { AdminAnalyticsModule } from './modules/admin-analytics/admin-analytics.module';
37+
import { MonetizationModule } from './modules/monetization/monetization.module';
3738

3839
@Module({
3940
imports: [
@@ -95,6 +96,7 @@ import { AdminAnalyticsModule } from './modules/admin-analytics/admin-analytics.
9596
PerksModule,
9697
PerksBoostsModule,
9798
AdminAnalyticsModule,
99+
MonetizationModule,
98100
],
99101
controllers: [AppController, HealthController],
100102
providers: [

backend/src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
88
import { LoggerService } from './common/logger/logger.service';
99

1010
async function bootstrap() {
11-
const app = await NestFactory.create(AppModule);
11+
const app = await NestFactory.create(AppModule, { rawBody: true });
1212

1313
// Use Winston logger
1414
const winstonLogger = app.get(WINSTON_MODULE_NEST_PROVIDER);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { CouponService } from './couponService';
2+
3+
describe('CouponService', () => {
4+
const couponsService = {
5+
validateCoupon: jest.fn(),
6+
applyCoupon: jest.fn(),
7+
};
8+
9+
let service: CouponService;
10+
11+
beforeEach(() => {
12+
jest.clearAllMocks();
13+
service = new CouponService(couponsService as any);
14+
});
15+
16+
it('returns invalid response for invalid coupons', async () => {
17+
couponsService.validateCoupon.mockResolvedValue({ valid: false, message: 'Invalid coupon code' });
18+
19+
const result = await service.validateAndApplyCoupon({
20+
code: 'BADCODE',
21+
shopItemId: 10,
22+
purchaseAmount: 100,
23+
});
24+
25+
expect(result).toEqual({
26+
valid: false,
27+
reason: 'Invalid coupon code',
28+
originalAmount: 100,
29+
discountAmount: 0,
30+
finalAmount: 100,
31+
});
32+
expect(couponsService.applyCoupon).not.toHaveBeenCalled();
33+
});
34+
35+
it('applies discount for valid coupons', async () => {
36+
couponsService.validateCoupon.mockResolvedValue({ valid: true, message: 'Coupon is valid' });
37+
couponsService.applyCoupon.mockResolvedValue(25);
38+
39+
const result = await service.validateAndApplyCoupon({
40+
code: 'SAVE25',
41+
shopItemId: 10,
42+
purchaseAmount: 100,
43+
});
44+
45+
expect(result).toEqual({
46+
valid: true,
47+
reason: 'Coupon is valid',
48+
originalAmount: 100,
49+
discountAmount: 25,
50+
finalAmount: 75,
51+
});
52+
});
53+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { CouponsService } from '../../coupons/coupons.service';
3+
4+
@Injectable()
5+
export class CouponService {
6+
constructor(private readonly couponsService: CouponsService) {}
7+
8+
async validateAndApplyCoupon(params: {
9+
code: string;
10+
shopItemId: number;
11+
purchaseAmount: number;
12+
}) {
13+
const validation = await this.couponsService.validateCoupon({
14+
code: params.code,
15+
shop_item_id: params.shopItemId,
16+
purchase_amount: params.purchaseAmount,
17+
});
18+
19+
if (!validation.valid) {
20+
return {
21+
valid: false,
22+
reason: validation.message,
23+
originalAmount: params.purchaseAmount,
24+
discountAmount: 0,
25+
finalAmount: params.purchaseAmount,
26+
};
27+
}
28+
29+
const discountAmount = await this.couponsService.applyCoupon(
30+
params.code,
31+
params.shopItemId,
32+
params.purchaseAmount,
33+
);
34+
35+
return {
36+
valid: true,
37+
reason: validation.message,
38+
originalAmount: params.purchaseAmount,
39+
discountAmount,
40+
finalAmount: Math.max(0, params.purchaseAmount - discountAmount),
41+
};
42+
}
43+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Body, Controller, Headers, Post, Req, BadRequestException } from '@nestjs/common';
2+
import { Request } from 'express';
3+
import { PaymentWebhook } from './webhooks/paymentWebhook';
4+
import type { PaymentWebhookEvent } from './webhooks/paymentWebhook';
5+
import { CouponService } from './coupons/couponService';
6+
import { EventRewards, MonetizationEvent } from './rewards/eventRewards';
7+
8+
@Controller('monetization')
9+
export class MonetizationController {
10+
constructor(
11+
private readonly paymentWebhook: PaymentWebhook,
12+
private readonly couponService: CouponService,
13+
private readonly eventRewards: EventRewards,
14+
) {}
15+
16+
@Post('webhooks/payment')
17+
async handlePaymentWebhook(
18+
@Req() req: Request & { rawBody?: Buffer },
19+
@Headers('stripe-signature') stripeSignature: string | undefined,
20+
@Body() event: PaymentWebhookEvent,
21+
) {
22+
const forwardedProto = req.headers['x-forwarded-proto'];
23+
if (forwardedProto && String(forwardedProto).toLowerCase() !== 'https') {
24+
throw new BadRequestException('Webhook endpoint requires HTTPS');
25+
}
26+
27+
const payload = req.rawBody
28+
? req.rawBody.toString('utf8')
29+
: JSON.stringify(event);
30+
31+
return this.paymentWebhook.handlePaymentWebhook(payload, stripeSignature, event);
32+
}
33+
34+
@Post('coupons/validate')
35+
async validateCoupon(
36+
@Body() body: { code: string; shopItemId: number; purchaseAmount: number },
37+
) {
38+
return this.couponService.validateAndApplyCoupon(body);
39+
}
40+
41+
@Post('rewards/events')
42+
async processRewardEvent(
43+
@Body()
44+
body: {
45+
event: MonetizationEvent;
46+
payload: {
47+
userId: number;
48+
level?: number;
49+
perkId?: number;
50+
quantity?: number;
51+
grantedBy?: string;
52+
};
53+
},
54+
) {
55+
return this.eventRewards.processEvent(body.event, body.payload);
56+
}
57+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { MonetizationController } from './monetization.controller';
4+
import { PaymentWebhook } from './webhooks/paymentWebhook';
5+
import { RewardEngine } from './rewards/rewardEngine';
6+
import { CouponService } from './coupons/couponService';
7+
import { EventRewards } from './rewards/eventRewards';
8+
import { Purchase } from '../shop/entities/purchase.entity';
9+
import { ShopModule } from '../shop/shop.module';
10+
import { CouponsModule } from '../coupons/coupons.module';
11+
import { PerksModule } from '../perks/perks.module';
12+
import { PerksBoostsModule } from '../perks-boosts/perks-boosts.module';
13+
14+
@Module({
15+
imports: [
16+
TypeOrmModule.forFeature([Purchase]),
17+
ShopModule,
18+
CouponsModule,
19+
PerksModule,
20+
PerksBoostsModule,
21+
],
22+
controllers: [MonetizationController],
23+
providers: [PaymentWebhook, RewardEngine, CouponService, EventRewards],
24+
exports: [PaymentWebhook, RewardEngine, CouponService, EventRewards],
25+
})
26+
export class MonetizationModule {}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { BadRequestException } from '@nestjs/common';
2+
import { EventRewards } from './eventRewards';
3+
4+
describe('EventRewards', () => {
5+
const rewardEngine = {
6+
earnPerk: jest.fn(),
7+
grantPromotionalPerk: jest.fn(),
8+
};
9+
10+
let service: EventRewards;
11+
12+
beforeEach(() => {
13+
jest.clearAllMocks();
14+
rewardEngine.earnPerk.mockResolvedValue({ granted: true });
15+
rewardEngine.grantPromotionalPerk.mockResolvedValue({ granted: true, promotional: true });
16+
service = new EventRewards(rewardEngine as any);
17+
});
18+
19+
it('grants rewards on level.up when level threshold is met', async () => {
20+
await service.processEvent('level.up', { userId: 1, level: 10, perkId: 7, quantity: 1 });
21+
22+
expect(rewardEngine.earnPerk).toHaveBeenCalledWith({
23+
userId: 1,
24+
perkId: 7,
25+
quantity: 1,
26+
source: 'event:level.up',
27+
});
28+
});
29+
30+
it('returns no reward below level threshold', async () => {
31+
const result = await service.processEvent('level.up', { userId: 1, level: 9, perkId: 7 });
32+
expect(result).toEqual({ granted: false, reason: 'No reward rule matched' });
33+
});
34+
35+
it('requires grantedBy for promotional grants', async () => {
36+
await expect(
37+
service.processEvent('admin.promotional.grant', { userId: 1, perkId: 7 }),
38+
).rejects.toThrow(BadRequestException);
39+
});
40+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Injectable, BadRequestException } from '@nestjs/common';
2+
import { RewardEngine } from './rewardEngine';
3+
4+
export type MonetizationEvent =
5+
| 'level.up'
6+
| 'first.purchase'
7+
| 'daily.login'
8+
| 'admin.promotional.grant';
9+
10+
interface EventRewardPayload {
11+
userId: number;
12+
level?: number;
13+
perkId?: number;
14+
quantity?: number;
15+
grantedBy?: string;
16+
}
17+
18+
@Injectable()
19+
export class EventRewards {
20+
constructor(private readonly rewardEngine: RewardEngine) {}
21+
22+
async processEvent(event: MonetizationEvent, payload: EventRewardPayload) {
23+
switch (event) {
24+
case 'level.up':
25+
if ((payload.level || 0) < 10 || !payload.perkId) {
26+
return { granted: false, reason: 'No reward rule matched' };
27+
}
28+
return this.rewardEngine.earnPerk({
29+
userId: payload.userId,
30+
perkId: payload.perkId,
31+
quantity: payload.quantity || 1,
32+
source: 'event:level.up',
33+
});
34+
35+
case 'first.purchase':
36+
case 'daily.login':
37+
if (!payload.perkId) {
38+
return { granted: false, reason: 'perkId required for event reward' };
39+
}
40+
return this.rewardEngine.earnPerk({
41+
userId: payload.userId,
42+
perkId: payload.perkId,
43+
quantity: payload.quantity || 1,
44+
source: `event:${event}`,
45+
});
46+
47+
case 'admin.promotional.grant':
48+
if (!payload.perkId || !payload.grantedBy) {
49+
throw new BadRequestException('perkId and grantedBy are required for promotional grants');
50+
}
51+
return this.rewardEngine.grantPromotionalPerk({
52+
userId: payload.userId,
53+
perkId: payload.perkId,
54+
quantity: payload.quantity || 1,
55+
grantedBy: payload.grantedBy,
56+
});
57+
58+
default:
59+
return { granted: false, reason: 'Unsupported event' };
60+
}
61+
}
62+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { BadRequestException } from '@nestjs/common';
2+
import { RewardEngine } from './rewardEngine';
3+
4+
describe('RewardEngine', () => {
5+
let engine: RewardEngine;
6+
7+
const inventoryService = {
8+
addPerksToInventory: jest.fn(),
9+
};
10+
11+
const perksService = {
12+
findOnePublic: jest.fn(),
13+
};
14+
15+
beforeEach(() => {
16+
jest.clearAllMocks();
17+
perksService.findOnePublic.mockResolvedValue({ id: 1, price: '10.00' });
18+
engine = new RewardEngine(inventoryService as any, perksService as any);
19+
});
20+
21+
it('buys a perk using server-side price', async () => {
22+
const result = await engine.buyPerk({
23+
userId: 42,
24+
perkId: 1,
25+
quantity: 2,
26+
availableCurrency: 30,
27+
clientUnitPrice: 10,
28+
});
29+
30+
expect(result).toEqual({
31+
purchased: true,
32+
totalCost: 20,
33+
remainingCurrency: 10,
34+
});
35+
expect(inventoryService.addPerksToInventory).toHaveBeenCalledWith(42, [{ perkId: 1, quantity: 2 }]);
36+
});
37+
38+
it('rejects client-side price mismatch', async () => {
39+
await expect(
40+
engine.buyPerk({
41+
userId: 42,
42+
perkId: 1,
43+
quantity: 1,
44+
availableCurrency: 100,
45+
clientUnitPrice: 1,
46+
}),
47+
).rejects.toThrow(BadRequestException);
48+
});
49+
50+
it('rejects insufficient currency', async () => {
51+
await expect(
52+
engine.buyPerk({
53+
userId: 42,
54+
perkId: 1,
55+
quantity: 2,
56+
availableCurrency: 5,
57+
}),
58+
).rejects.toThrow(BadRequestException);
59+
});
60+
61+
it('rate limits excessive purchase attempts', async () => {
62+
for (let i = 0; i < 10; i += 1) {
63+
await engine.buyPerk({
64+
userId: 99,
65+
perkId: 1,
66+
quantity: 1,
67+
availableCurrency: 999,
68+
});
69+
}
70+
71+
await expect(
72+
engine.buyPerk({
73+
userId: 99,
74+
perkId: 1,
75+
quantity: 1,
76+
availableCurrency: 999,
77+
}),
78+
).rejects.toThrow(BadRequestException);
79+
});
80+
});

0 commit comments

Comments
 (0)