Skip to content

Commit e67464f

Browse files
committed
feat(subscription): add subscription module and web route
1 parent 23b81dd commit e67464f

14 files changed

Lines changed: 2328 additions & 1 deletion

File tree

apps/web/src/main.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,15 @@ import { ChangePasswordView } from "./views/ChangePasswordView";
2424
import { ForgotPasswordView } from "./views/ForgotPasswordView";
2525
import { SetupWizardView } from "./views/SetupWizardView";
2626
import { LedgerView } from "../../../modules/ledger/frontend/LedgerView";
27+
import { SubscriptionView } from "../../../modules/subscription/frontend/SubscriptionView";
2728
import { MODULE_SUB_NAV } from "./moduleConfig";
2829

2930
// ─── Helpers ──────────────────────────────────────────────────
3031

3132
// 코어 라우트 목록 (앱 shell 없이 전체 화면으로 렌더되는 것 제외)
3233
const CORE_ROUTES = ["login", "forgot-password", "home", "marketplace", "admin", "change-password"] as const;
3334
// 모듈 라우트 — module.json name 기준 (서버 레지스트리와 일치)
34-
const MODULE_ROUTES: string[] = ["ledger"];
35+
const MODULE_ROUTES: string[] = ["ledger", "subscription"];
3536

3637
/** 해시에서 베이스 라우트만 추출 ("ledger/import" → "ledger") */
3738
function getRouteFromHash(rawHash: string): RouteKey {
@@ -520,6 +521,7 @@ function App() {
520521
)}
521522
{/* ── 모듈 뷰 ────────────────────────────────────── */}
522523
{effectiveRoute === "ledger" && <LedgerView subRoute={subRoute} />}
524+
{effectiveRoute === "subscription" && <SubscriptionView />}
523525
{isSettingsOpen && (
524526
<SettingsView
525527
theme={theme}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { Router } from 'express';
2+
3+
import type { DbProvider } from '@fieldstack/core' with { 'resolution-mode': 'import' };
4+
5+
import { SubscriptionService } from './service.js';
6+
import { createSubscriptionRouter } from './routes.js';
7+
8+
// ── 모듈 서비스 계약 (duck-type) ──────────────────────────────────
9+
interface JwtManager {
10+
verifyAccessToken(token: string): Promise<{ userId: string; email: string }>;
11+
}
12+
13+
interface EventBus {
14+
emit(event: string, payload: unknown): void;
15+
on(event: string, handler: (payload: unknown) => void): () => void;
16+
}
17+
18+
interface Scheduler {
19+
register(def: {
20+
name: string;
21+
cronExpr: string;
22+
handler: () => Promise<void> | void;
23+
timezone?: string;
24+
}): void;
25+
unregister(name: string): boolean;
26+
}
27+
28+
interface ModuleServices {
29+
db: DbProvider;
30+
jwtManager: JwtManager;
31+
eventBus: EventBus;
32+
scheduler: Scheduler;
33+
}
34+
35+
// ── 리스너 해제 함수 보관 ─────────────────────────────────────────
36+
let unsubscribePaymentListener: (() => void) | null = null;
37+
38+
export function createRouter(services: ModuleServices): Router {
39+
const { db, jwtManager, eventBus, scheduler } = services;
40+
const service = new SubscriptionService(db);
41+
42+
// ── Scheduler: 매일 자정 결제일 체크 ──────────────────────────
43+
scheduler.register({
44+
name: 'subscription-payment-check',
45+
cronExpr: '0 0 * * *',
46+
timezone: 'Asia/Seoul',
47+
handler: async () => {
48+
const dueSubs = await service.findDueToday();
49+
for (const sub of dueSubs) {
50+
eventBus.emit('subscription:payment', {
51+
subscriptionId: sub.id,
52+
userId: sub.userId,
53+
serviceName: sub.serviceName,
54+
amount: sub.currentAmount,
55+
currency: sub.currency,
56+
date: sub.nextPaymentDate,
57+
priceHistoryId: null,
58+
});
59+
// 다음 결제일 자동 갱신
60+
await service.advanceNextPaymentDate(sub.id);
61+
}
62+
},
63+
});
64+
65+
// ── EventBus: Ledger 자동 기록을 위한 이벤트 발행 확인 ──────────
66+
// (Ledger 모듈이 'subscription:payment'를 구독해 자동 기록)
67+
// 이 모듈은 발행 측이므로 별도 구독 불필요.
68+
//
69+
// 추후 'subscription:price-changed' 이벤트 수신 측 연동 예시:
70+
// unsubscribePaymentListener = eventBus.on('ledger:created', (payload) => { ... });
71+
72+
return createSubscriptionRouter(service, jwtManager);
73+
}
74+
75+
/** 모듈 언로드 시 호출 (graceful shutdown) */
76+
export function shutdown(services: Pick<ModuleServices, 'scheduler'>): void {
77+
services.scheduler.unregister('subscription-payment-check');
78+
unsubscribePaymentListener?.();
79+
unsubscribePaymentListener = null;
80+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
-- ── 001_initial.sql ─────────────────────────────────────────────
2+
-- Subscription 모듈 초기 스키마: 구독 서비스 · 가격 히스토리
3+
4+
-- ── 구독 서비스 ───────────────────────────────────────────────────
5+
CREATE TABLE IF NOT EXISTS subscription_services (
6+
id {{UUID_PRIMARY_KEY}},
7+
user_id UUID NOT NULL,
8+
service_name TEXT NOT NULL,
9+
current_amount DECIMAL(15,2) NOT NULL CHECK (current_amount >= 0),
10+
currency TEXT NOT NULL DEFAULT 'KRW',
11+
billing_cycle TEXT NOT NULL CHECK (billing_cycle IN ('monthly', 'yearly')),
12+
billing_day INTEGER NOT NULL CHECK (billing_day BETWEEN 1 AND 31),
13+
started_at DATE NOT NULL DEFAULT CURRENT_DATE,
14+
next_payment_date DATE NOT NULL,
15+
is_active BOOLEAN NOT NULL DEFAULT {{BOOLEAN_FALSE}},
16+
cancelled_at DATE,
17+
category TEXT,
18+
description TEXT,
19+
url TEXT,
20+
tags TEXT, -- JSON 배열 문자열
21+
total_paid DECIMAL(15,2) NOT NULL DEFAULT 0,
22+
created_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}},
23+
updated_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}}
24+
);
25+
26+
CREATE INDEX IF NOT EXISTS idx_subscription_services_user_id
27+
ON subscription_services (user_id);
28+
29+
CREATE INDEX IF NOT EXISTS idx_subscription_services_next_payment
30+
ON subscription_services (next_payment_date);
31+
32+
-- ── 가격 히스토리 ─────────────────────────────────────────────────
33+
CREATE TABLE IF NOT EXISTS subscription_price_history (
34+
id {{UUID_PRIMARY_KEY}},
35+
subscription_id UUID NOT NULL REFERENCES subscription_services(id) ON DELETE CASCADE,
36+
effective_date DATE NOT NULL,
37+
amount DECIMAL(15,2) NOT NULL CHECK (amount >= 0),
38+
currency TEXT NOT NULL DEFAULT 'KRW',
39+
reason TEXT,
40+
note TEXT,
41+
created_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}}
42+
);
43+
44+
CREATE INDEX IF NOT EXISTS idx_subscription_price_history_subscription_id
45+
ON subscription_price_history (subscription_id);
46+
47+
CREATE INDEX IF NOT EXISTS idx_subscription_price_history_effective_date
48+
ON subscription_price_history (effective_date);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- ── 002_add_started_at.sql ───────────────────────────────────────
2+
-- subscription_services 테이블에 구독 시작일 컬럼 추가
3+
4+
ALTER TABLE subscription_services
5+
ADD COLUMN IF NOT EXISTS started_at DATE NOT NULL DEFAULT CURRENT_DATE;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- ── 003_subscription_notes.sql ──────────────────────────────────
2+
-- 구독별 메모 테이블
3+
4+
CREATE TABLE IF NOT EXISTS subscription_notes (
5+
id {{UUID_PRIMARY_KEY}},
6+
subscription_id UUID NOT NULL REFERENCES subscription_services(id) ON DELETE CASCADE,
7+
content TEXT NOT NULL,
8+
created_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}}
9+
);
10+
11+
CREATE INDEX IF NOT EXISTS idx_subscription_notes_subscription_id
12+
ON subscription_notes (subscription_id);
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { Router } from 'express';
2+
3+
import type { SubscriptionService } from './service.js';
4+
import {
5+
createNoteSchema,
6+
createPriceHistorySchema,
7+
createSubscriptionSchema,
8+
updateSubscriptionSchema,
9+
} from './validation.js';
10+
11+
type JwtManager = {
12+
verifyAccessToken(token: string): Promise<{ userId: string; email: string }>;
13+
};
14+
15+
// ── 인증 미들웨어 (Ledger 패턴 동일) ─────────────────────────────
16+
17+
function makeAuth(jwtManager: JwtManager) {
18+
return async (
19+
req: Parameters<Router['use']>[0] extends (...args: infer A) => unknown ? A[0] : never,
20+
res: Parameters<Router['use']>[0] extends (...args: infer A) => unknown ? A[1] : never,
21+
next: Parameters<Router['use']>[0] extends (...args: infer A) => unknown ? A[2] : never,
22+
): Promise<void> => {
23+
const header = (req as { headers: Record<string, string | undefined> }).headers['authorization'];
24+
if (!header?.startsWith('Bearer ')) {
25+
(res as { status: (n: number) => { json: (b: unknown) => void } })
26+
.status(401)
27+
.json({ success: false, error: 'Unauthorized' });
28+
return;
29+
}
30+
try {
31+
const token = header.slice(7);
32+
const payload = await jwtManager.verifyAccessToken(token);
33+
(req as Record<string, unknown>)['userId'] = payload.userId;
34+
(next as () => void)();
35+
} catch {
36+
(res as { status: (n: number) => { json: (b: unknown) => void } })
37+
.status(401)
38+
.json({ success: false, error: 'Unauthorized' });
39+
}
40+
};
41+
}
42+
43+
export function createSubscriptionRouter(
44+
service: SubscriptionService,
45+
jwtManager: JwtManager,
46+
): Router {
47+
const router = Router();
48+
const auth = makeAuth(jwtManager);
49+
50+
// ── 구독 목록 / 생성 ──────────────────────────────────────────
51+
router.get('/services', auth, async (req, res) => {
52+
const userId = (req as Record<string, string>)['userId'];
53+
const items = await service.findAll(userId);
54+
res.json({ success: true, data: items });
55+
});
56+
57+
router.post('/services', auth, async (req, res) => {
58+
const userId = (req as Record<string, string>)['userId'];
59+
const parsed = createSubscriptionSchema.safeParse(req.body);
60+
if (!parsed.success) {
61+
res.status(400).json({ success: false, error: parsed.error.message });
62+
return;
63+
}
64+
const sub = await service.create(userId, parsed.data);
65+
res.status(201).json({ success: true, data: sub });
66+
});
67+
68+
// ── 구독 상세 / 수정 / 삭제 ──────────────────────────────────
69+
router.get('/services/:id', auth, async (req, res) => {
70+
const userId = (req as Record<string, string>)['userId'];
71+
const sub = await service.findById(userId, req.params['id']);
72+
if (!sub) { res.status(404).json({ success: false, error: 'Not found' }); return; }
73+
res.json({ success: true, data: sub });
74+
});
75+
76+
router.put('/services/:id', auth, async (req, res) => {
77+
const userId = (req as Record<string, string>)['userId'];
78+
const parsed = updateSubscriptionSchema.safeParse(req.body);
79+
if (!parsed.success) {
80+
res.status(400).json({ success: false, error: parsed.error.message });
81+
return;
82+
}
83+
const sub = await service.update(userId, req.params['id'], parsed.data);
84+
if (!sub) { res.status(404).json({ success: false, error: 'Not found' }); return; }
85+
res.json({ success: true, data: sub });
86+
});
87+
88+
router.delete('/services/:id', auth, async (req, res) => {
89+
const userId = (req as Record<string, string>)['userId'];
90+
const ok = await service.delete(userId, req.params['id']);
91+
if (!ok) { res.status(404).json({ success: false, error: 'Not found' }); return; }
92+
res.json({ success: true });
93+
});
94+
95+
// ── 요약 통계 ─────────────────────────────────────────────────
96+
router.get('/summary', auth, async (req, res) => {
97+
const userId = (req as Record<string, string>)['userId'];
98+
const summary = await service.getSummary(userId);
99+
res.json({ success: true, data: summary });
100+
});
101+
102+
// ── 가격 히스토리 ─────────────────────────────────────────────
103+
router.post('/services/:id/price', auth, async (req, res) => {
104+
const userId = (req as Record<string, string>)['userId'];
105+
const sub = await service.findById(userId, req.params['id']);
106+
if (!sub) { res.status(404).json({ success: false, error: 'Not found' }); return; }
107+
108+
const parsed = createPriceHistorySchema.safeParse(req.body);
109+
if (!parsed.success) {
110+
res.status(400).json({ success: false, error: parsed.error.message });
111+
return;
112+
}
113+
114+
const history = await service.addPriceHistory(req.params['id'], parsed.data);
115+
res.status(201).json({ success: true, data: history });
116+
});
117+
118+
router.get('/services/:id/history', auth, async (req, res) => {
119+
const userId = (req as Record<string, string>)['userId'];
120+
const sub = await service.findById(userId, req.params['id']);
121+
if (!sub) { res.status(404).json({ success: false, error: 'Not found' }); return; }
122+
123+
const history = await service.getPriceHistory(req.params['id']);
124+
res.json({ success: true, data: history });
125+
});
126+
127+
// ── 메모 ──────────────────────────────────────────────────────
128+
router.get('/services/:id/notes', auth, async (req, res) => {
129+
const userId = (req as Record<string, string>)['userId'];
130+
const sub = await service.findById(userId, req.params['id']);
131+
if (!sub) { res.status(404).json({ success: false, error: 'Not found' }); return; }
132+
const notes = await service.getNotes(req.params['id']);
133+
res.json({ success: true, data: notes });
134+
});
135+
136+
router.post('/services/:id/notes', auth, async (req, res) => {
137+
const userId = (req as Record<string, string>)['userId'];
138+
const sub = await service.findById(userId, req.params['id']);
139+
if (!sub) { res.status(404).json({ success: false, error: 'Not found' }); return; }
140+
const parsed = createNoteSchema.safeParse(req.body);
141+
if (!parsed.success) {
142+
res.status(400).json({ success: false, error: parsed.error.message });
143+
return;
144+
}
145+
const note = await service.addNote(req.params['id'], parsed.data);
146+
res.status(201).json({ success: true, data: note });
147+
});
148+
149+
router.delete('/services/:id/notes/:noteId', auth, async (req, res) => {
150+
const userId = (req as Record<string, string>)['userId'];
151+
const sub = await service.findById(userId, req.params['id']);
152+
if (!sub) { res.status(404).json({ success: false, error: 'Not found' }); return; }
153+
const ok = await service.deleteNote(req.params['id'], req.params['noteId']);
154+
if (!ok) { res.status(404).json({ success: false, error: 'Not found' }); return; }
155+
res.json({ success: true });
156+
});
157+
158+
// ── 누적 결제 금액 ────────────────────────────────────────────
159+
router.get('/services/:id/cumulative', auth, async (req, res) => {
160+
const userId = (req as Record<string, string>)['userId'];
161+
const result = await service.getCumulative(userId, req.params['id']);
162+
if (!result) { res.status(404).json({ success: false, error: 'Not found' }); return; }
163+
res.json({ success: true, data: result });
164+
});
165+
166+
return router;
167+
}

0 commit comments

Comments
 (0)