Skip to content
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
6 changes: 2 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ services:
app: # μ„œλΉ„μŠ€λͺ…
build: .
container_name: myapp # μ»¨ν…Œμ΄λ„ˆλͺ…
command: >
sh -c "pnpm start"
command: ["node", "dist/index.js"]
env_file: # .env κ°€μ Έμ˜€κΈ°
- .env
environment:
Expand All @@ -28,8 +27,7 @@ services:
app-dev: # μ„œλΉ„μŠ€λͺ…
build: .
container_name: myapp-dev # μ»¨ν…Œμ΄λ„ˆλͺ…
command: >
sh -c "pnpm start"
command: ["node", "dist/index.js"]
env_file: # .env κ°€μ Έμ˜€κΈ°
- .env.dev
environment:
Expand Down
58 changes: 46 additions & 12 deletions src/purchases/controller/purchase.webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,59 @@ import { Request, Response, NextFunction } from 'express';
import { WebhookService } from '../services/purchase.webhook.service';
import { PayplePaymentResult } from '../utils/payple';

type RedirectStatus = 'success' | 'fail' | 'error' | 'invalid';

const buildRedirectUrl = (
status: RedirectStatus,
params: Record<string, string | undefined>,
): string | null => {
const base = process.env.PURCHASE_RESULT_REDIRECT_URL;
if (!base) return null;

try {
const url = new URL(base);
url.searchParams.set('status', status);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== '') url.searchParams.set(key, value);
});
return url.toString();
} catch {
return null;
}
};

export const WebhookController = {
async handleWebhook(req: Request, res: Response, next: NextFunction) {
try {
const result = req.body as Partial<PayplePaymentResult>;
async handleWebhook(req: Request, res: Response, _next: NextFunction) {
const result = req.body as Partial<PayplePaymentResult> | undefined;
const oid = typeof result?.PCD_PAY_OID === 'string' ? result.PCD_PAY_OID : undefined;

if (!result || typeof result.PCD_PAY_RST !== 'string') {
return res.status(400).send('Invalid payload');
}
const redirect = (
status: RedirectStatus,
params: Record<string, string | undefined> = {},
) => {
const url = buildRedirectUrl(status, { oid, ...params });
if (url) return res.redirect(302, url);
return res.status(200).send('OK');
};

if (result.PCD_PAY_RST !== 'success') {
console.log('[Webhook] Non-success result:', result.PCD_PAY_CODE, result.PCD_PAY_MSG);
return res.status(200).send('OK');
}
if (!result || typeof result.PCD_PAY_RST !== 'string') {
return redirect('invalid');
}

if (result.PCD_PAY_RST !== 'success') {
console.log('[Webhook] Non-success result:', result.PCD_PAY_CODE, result.PCD_PAY_MSG);
return redirect('fail', {
code: result.PCD_PAY_CODE,
message: result.PCD_PAY_MSG,
});
}

try {
await WebhookService.handlePaypleResult(result as PayplePaymentResult);
res.status(200).send('OK');
return redirect('success');
} catch (err) {
console.error('[Webhook] Error:', err);
res.status(500).send('Internal Server Error');
return redirect('error');
}
},
};
1 change: 1 addition & 0 deletions src/purchases/dtos/purchase.request.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ export interface PurchaseRequestResponseDTO {
PCD_PAY_GOODS: string;
PCD_PAY_TOTAL: number;
PCD_USER_DEFINE1: string;
PCD_RST_URL: string;
}
1 change: 1 addition & 0 deletions src/purchases/services/purchase.request.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const PurchaseRequestService = {
prompt_id: dto.prompt_id,
user_id: userId,
}),
PCD_RST_URL: process.env.PAYPLE_RST_URL || '',
};
},
};
54 changes: 54 additions & 0 deletions src/settlements/controllers/settlement.history.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Request, Response } from 'express';
import { SettlementHistoryService } from '../services/settlement.history.service';

export const getMonthlySales = async (req: Request, res: Response) => {
try {
const user = req.user;
if (!user) {
return res.status(401).json({
error: 'Unauthorized',
message: '둜그인이 ν•„μš”ν•©λ‹ˆλ‹€.',
statusCode: 401,
});
}

const userId = (user as { user_id: number }).user_id;
const now = new Date();
const year = req.query.year ? Number(req.query.year) : now.getUTCFullYear();
const month = req.query.month ? Number(req.query.month) : now.getUTCMonth() + 1;

const result = await SettlementHistoryService.getMonthlySales(userId, year, month);
return res.status(200).json(result);
} catch (error: any) {
const status = error.status || 500;
return res.status(status).json({
error: error.type || 'InternalServerError',
message: error.message || 'μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.',
statusCode: status,
});
}
};

export const getYearlySettlements = async (req: Request, res: Response) => {
try {
const user = req.user;
if (!user) {
return res.status(401).json({
error: 'Unauthorized',
message: '둜그인이 ν•„μš”ν•©λ‹ˆλ‹€.',
statusCode: 401,
});
}

const userId = (user as { user_id: number }).user_id;
const result = await SettlementHistoryService.getYearlySettlements(userId);
return res.status(200).json(result);
} catch (error: any) {
const status = error.status || 500;
return res.status(status).json({
error: error.type || 'InternalServerError',
message: error.message || 'μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.',
statusCode: status,
});
}
};
46 changes: 46 additions & 0 deletions src/settlements/dtos/settlement.history.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export interface MonthlySalesItemDto {
settlement_id: number;
sold_at: string;
prompt_id: number;
prompt_title: string;
buyer_id: number;
buyer_nickname: string | null;
pay_type: string | null;
card_name: string | null;
sale_price: number;
settled_amount: number;
fee: number;
status: 'Pending' | 'Succeed' | 'Failed';
}

export interface MonthlySalesSummaryDto {
count: number;
total_sales: number;
total_settled: number;
total_fee: number;
}

export interface MonthlySalesResponseDto {
message: string;
year: number;
month: number;
summary: MonthlySalesSummaryDto;
items: MonthlySalesItemDto[];
statusCode: number;
}

export interface YearlySettlementItemDto {
year: number;
count: number;
total_sales: number;
total_settled: number;
total_fee: number;
succeeded_amount: number;
pending_amount: number;
}

export interface YearlySettlementResponseDto {
message: string;
items: YearlySettlementItemDto[];
statusCode: number;
}
87 changes: 87 additions & 0 deletions src/settlements/repositories/settlement.history.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import prisma from '../../config/prisma';
import { Status } from '@prisma/client';

export const SettlementHistoryRepository = {
async findSalesByMonth(userId: number, year: number, month: number) {
const start = new Date(Date.UTC(year, month - 1, 1));
const end = new Date(Date.UTC(year, month, 1));

return prisma.settlement.findMany({
where: {
user_id: userId,
created_at: { gte: start, lt: end },
},
orderBy: { created_at: 'desc' },
select: {
settlement_id: true,
amount: true,
fee: true,
status: true,
created_at: true,
payment: {
select: {
pay_type: true,
card_name: true,
purchase: {
select: {
amount: true,
user_id: true,
user: { select: { nickname: true } },
prompt: { select: { prompt_id: true, title: true } },
},
},
},
},
},
});
},

async aggregateYearlyTotals(userId: number): Promise<
Array<{
year: number;
count: number;
total_sales: number;
total_settled: number;
total_fee: number;
succeeded_amount: number;
pending_amount: number;
}>
> {
const rows = await prisma.$queryRaw<
Array<{
year: number;
count: bigint;
total_settled: bigint | null;
total_fee: bigint | null;
succeeded_amount: bigint | null;
pending_amount: bigint | null;
total_sales: bigint | null;
}>
>`
SELECT
YEAR(s.created_at) AS year,
COUNT(*) AS count,
SUM(s.amount) AS total_settled,
SUM(s.fee) AS total_fee,
SUM(CASE WHEN s.status = ${Status.Succeed} THEN s.amount ELSE 0 END) AS succeeded_amount,
SUM(CASE WHEN s.status = ${Status.Pending} THEN s.amount ELSE 0 END) AS pending_amount,
SUM(p.amount) AS total_sales
FROM Settlement s
JOIN Payment pm ON pm.payment_id = s.payment_id
JOIN Purchase p ON p.purchase_id = pm.purchase_id
WHERE s.user_id = ${userId}
GROUP BY YEAR(s.created_at)
ORDER BY year DESC
`;

return rows.map((r) => ({
year: Number(r.year),
count: Number(r.count),
total_sales: Number(r.total_sales ?? 0),
total_settled: Number(r.total_settled ?? 0),
total_fee: Number(r.total_fee ?? 0),
succeeded_amount: Number(r.succeeded_amount ?? 0),
pending_amount: Number(r.pending_amount ?? 0),
}));
},
};
Loading
Loading