Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
||||
import { logger } from 'firebase-functions';
|
||||
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||
|
||||
import { callerUid, num, str } from '../core/callable.js';
|
||||
import { ERR, fail, positiveInt } from '../core/errors.js';
|
||||
import { txIdOf } from '../core/ids.js';
|
||||
import { refundSplit, spendSplit } from '../ledger/ledger.js';
|
||||
import { verifiedQuestCountOf } from '../ledger/fpi.js';
|
||||
import { publicNameOf } from '../core/names.js';
|
||||
|
||||
/**
|
||||
* `fn-campaign-create` — 사용자 캠페인 개설(원장 B-56 자격 · B-57 등록비).
|
||||
*
|
||||
* 예전에는 앱이 `campaigns` 에 `draft` 로 직접 썼는데 개설자는 status 를 바꿀 수 없어
|
||||
* 심사(`review`)로 넘어갈 길이 없었다. 개설 · 등록비 차감 · `review` 진입을
|
||||
* 한 트랜잭션에서 하고, 승인은 그대로 `fn-campaign-admin-review` 가 한다.
|
||||
*
|
||||
* 등록비는 겸용이다(B-57 · B-06) — 일반 POP 을 먼저 쓰고 부족분만 기여 POP 에서 뺀다
|
||||
* (`spendSplit`, 원장 최대 2건). 그 거래 ID 들을 `feeTxIds` 에 남겨 반환(B-58)이 행마다
|
||||
* 원래 타입으로 돌린다. 등록비는 성장 · FPI 원천이 아니다(C-30).
|
||||
*
|
||||
* 🟡 운영값(등록비 · 가입 경과일 · 동시 진행 수 · 검증 활동 횟수)은 `config/campaign` 이 덮는다.
|
||||
* 「검증 활동」은 서버가 인정한 Quest 완료 누적 수다(해석 확인 요청 Campaign 개설자격 A안 · 5회).
|
||||
*/
|
||||
const DEFAULTS = { feePop: 100, minAccountDays: 7, maxActive: 1, minVerifiedQuests: 5 };
|
||||
const MAX_DAYS = 365;
|
||||
|
||||
export const fnCampaignCreate = onCall(async (req) => {
|
||||
const data = req.data as Record<string, unknown> | null;
|
||||
return createCampaign({
|
||||
uid: callerUid(req),
|
||||
clientSeq: str(data, 'clientSeq'),
|
||||
title: str(data, 'title'),
|
||||
body: str(data, 'body'),
|
||||
targetPop: positiveInt(num(data, 'targetPop'), 'targetPop'),
|
||||
days: positiveInt(num(data, 'days'), 'days'),
|
||||
});
|
||||
});
|
||||
|
||||
export async function createCampaign(input: {
|
||||
uid: string; clientSeq: string; title: string; body: string; targetPop: number; days: number;
|
||||
}): Promise<{ campaignId: string; status: 'review' }> {
|
||||
const { uid, clientSeq } = input;
|
||||
const title = input.title.trim();
|
||||
const body = input.body.trim();
|
||||
if (!title || title.length > 100) throw new HttpsError('invalid-argument', '제목은 1~100자입니다.');
|
||||
if (!body || body.length > 5000) throw new HttpsError('invalid-argument', '내용은 1~5000자입니다.');
|
||||
if (input.days > MAX_DAYS) throw new HttpsError('invalid-argument', `진행 기간은 ${MAX_DAYS}일 이하입니다.`);
|
||||
|
||||
const db = getFirestore();
|
||||
const key = `campaign:${uid}:${clientSeq}`;
|
||||
const campaignRef = db.doc(`campaigns/${txIdOf(key)}`);
|
||||
// 같은 요청의 재시도 — 등록비도 두 번 빼지 않는다(spendPop 도 같은 키로 멱등).
|
||||
if ((await campaignRef.get()).exists) return { campaignId: campaignRef.id, status: 'review' };
|
||||
|
||||
const cfg = { ...DEFAULTS, ...((await db.doc('config/campaign').get()).data() ?? {}) };
|
||||
const user = await db.doc(`users/${uid}`).get();
|
||||
const fandomId = user.get('fandomId') as string | undefined;
|
||||
if (!fandomId) fail(ERR.permissionDenied, '팬덤에 가입한 뒤 캠페인을 만들 수 있습니다.');
|
||||
if ((user.get('status') ?? 'active') !== 'active') {
|
||||
fail(ERR.permissionDenied, '이용이 제한된 계정은 캠페인을 만들 수 없습니다.');
|
||||
}
|
||||
const joined = user.get('createdAt') as Timestamp | undefined;
|
||||
if (!joined || Date.now() - joined.toMillis() < cfg.minAccountDays * 86_400_000) {
|
||||
fail(ERR.permissionDenied, `가입 ${cfg.minAccountDays}일이 지나야 캠페인을 만들 수 있습니다.`);
|
||||
}
|
||||
const need = Number(cfg.minVerifiedQuests);
|
||||
const done = need > 0 ? await verifiedQuestCountOf(uid) : 0;
|
||||
if (done < need) {
|
||||
fail(ERR.campaignNotEligible, `퀘스트 완료 ${done}/${need} — 퀘스트를 ${need - done}번 더 완료하면 캠페인을 열 수 있습니다.`, { done, need });
|
||||
}
|
||||
// 단일 필드 조건만 써서 복합 색인 없이 센다. 개설자 한 명의 캠페인 수는 작다.
|
||||
const mine = await db.collection('campaigns').where('creatorUid', '==', uid).get();
|
||||
const active = mine.docs.filter((d) => ['review', 'funding'].includes(d.get('status'))).length;
|
||||
if (active >= cfg.maxActive) {
|
||||
fail(ERR.permissionDenied, `진행 중인 캠페인은 ${cfg.maxActive}건까지입니다.`);
|
||||
}
|
||||
|
||||
await spendSplit({
|
||||
uid,
|
||||
amount: cfg.feePop,
|
||||
reasonCode: 'CAMPAIGN_FEE',
|
||||
idempotencyKey: `campaignFee:${uid}:${clientSeq}`,
|
||||
refPath: campaignRef.path,
|
||||
alsoWrite: (tx, parts) => tx.create(campaignRef, {
|
||||
fandomId,
|
||||
creatorUid: uid,
|
||||
creatorName: publicNameOf(user),
|
||||
title,
|
||||
summary: '',
|
||||
body,
|
||||
targetPop: input.targetPop,
|
||||
endsAt: Timestamp.fromMillis(Date.now() + input.days * 86_400_000),
|
||||
status: 'review',
|
||||
raisedPop: 0,
|
||||
achievedRate: 0,
|
||||
donorCount: 0,
|
||||
refundState: 'none',
|
||||
feePop: cfg.feePop,
|
||||
feeTxIds: parts.map((p) => p.txId),
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}),
|
||||
});
|
||||
return { campaignId: campaignRef.id, status: 'review' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록비 반환 — B-58 「회사귀책 · 반려는 등록비 반환」 · 공개 전 철회(B-57 「공개 확정 시 Burn」).
|
||||
*
|
||||
* 반환이 정해진 캠페인은 상태 전이와 같은 쓰기에서 `feeRefund: 'due'` 를 받는다. 여기서
|
||||
* 행마다 원래 타입으로 돌려준 뒤 `done` 으로 닫는다. 캠페인 하나에 멱등키 하나라 중간에
|
||||
* 실패해도 다시 부르면 두 번 돌려주지 않는다. `due` 가 아니면 이미 남긴 결과만 돌려준다.
|
||||
*/
|
||||
export async function settleFeeRefund(campaignId: string): Promise<string[]> {
|
||||
const ref = getFirestore().doc(`campaigns/${campaignId}`);
|
||||
const snap = await ref.get();
|
||||
if (snap.get('feeRefund') !== 'due') {
|
||||
return (snap.get('feeRefundTxIds') as string[] | undefined) ?? [];
|
||||
}
|
||||
// 분할 차감 전의 옛 캠페인은 `feeTxId` 한 건이다. 둘 다 없으면 면제(Official 등)다.
|
||||
const legacy = snap.get('feeTxId') as string | undefined;
|
||||
const txIds = (snap.get('feeTxIds') as string[] | undefined) ?? (legacy ? [legacy] : []);
|
||||
const refunded = txIds.length === 0 ? [] : (await refundSplit({
|
||||
uid: snap.get('creatorUid') as string,
|
||||
originalTxIds: txIds,
|
||||
idempotencyKey: `campaignFeeRefund:${campaignId}`,
|
||||
reasonCode: 'CAMPAIGN_FEE_REFUND',
|
||||
})).map((r) => r.txId);
|
||||
await ref.update({
|
||||
feeRefund: 'done',
|
||||
feeRefundTxIds: refunded,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
return refunded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 반환이 정해졌는데 끝나지 않은 캠페인을 다시 돈다 — `fn-campaign-settle` 회차마다.
|
||||
* 상태 전이 뒤 반환 전에 죽은 호출을 복구하는 경로다. 실패한 건은 다음 회차가 다시 집는다.
|
||||
*/
|
||||
export async function retryFeeRefunds(): Promise<number> {
|
||||
const due = await getFirestore().collection('campaigns').where('feeRefund', '==', 'due').get();
|
||||
let done = 0;
|
||||
for (const doc of due.docs) {
|
||||
try {
|
||||
await settleFeeRefund(doc.id);
|
||||
done += 1;
|
||||
} catch (error) {
|
||||
logger.error('[campaign] 등록비 반환 실패 — 다음 회차 재시도', { campaignId: doc.id, error: String(error) });
|
||||
}
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
/**
|
||||
* `fn-campaign-withdraw` — 개설자 철회(B-58).
|
||||
*
|
||||
* - 공개 전(`draft` · `review`) — 철회하고 등록비를 돌려준다. 등록비는 공개 확정 때
|
||||
* Burn 되므로(B-57) 아직 확정되지 않았다.
|
||||
* - 공개 후 첫 후원 전(`funding` · 후원 0건) — 철회는 되지만 **생성자 변심이라 등록비는
|
||||
* 비환불**이다.
|
||||
* - 첫 후원 뒤 — 개설자 단독으로는 못 한다. 운영 승인 경로다(E-PERM-01).
|
||||
*
|
||||
* 판정과 상태 전이를 한 트랜잭션에 둔다. 후원(`fn-campaign-fund`)도 같은 문서의 `status`
|
||||
* 를 트랜잭션으로 읽으므로 「후원 0건이라 철회」와 「첫 후원」이 함께 서지 않는다.
|
||||
*/
|
||||
export const fnCampaignWithdraw = onCall(async (req) =>
|
||||
withdrawCampaign(callerUid(req), str(req.data, 'campaignId')));
|
||||
|
||||
export async function withdrawCampaign(
|
||||
uid: string, campaignId: string,
|
||||
): Promise<{ status: 'withdrawn'; feeRefundTxIds: string[] }> {
|
||||
const db = getFirestore();
|
||||
const ref = db.doc(`campaigns/${campaignId}`);
|
||||
await db.runTransaction(async (tx) => {
|
||||
const snap = await tx.get(ref);
|
||||
if (!snap.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
|
||||
if (snap.get('creatorUid') !== uid) fail(ERR.permissionDenied, '개설자만 철회할 수 있습니다.');
|
||||
const status = snap.get('status') as string | undefined;
|
||||
if (status === 'withdrawn') return; // 재시도 — 남은 반환만 아래에서 마저 한다.
|
||||
const beforePublish = status === 'draft' || status === 'review';
|
||||
const noDonation = status === 'funding'
|
||||
&& ((snap.get('donorCount') as number | undefined) ?? 0) === 0
|
||||
&& ((snap.get('raisedPop') as number | undefined) ?? 0) === 0;
|
||||
if (!beforePublish && !noDonation) {
|
||||
fail(ERR.permissionDenied, '첫 후원 뒤에는 운영 승인 없이 철회할 수 없습니다.');
|
||||
}
|
||||
tx.update(ref, {
|
||||
status: 'withdrawn',
|
||||
withdrawnAt: FieldValue.serverTimestamp(),
|
||||
...(beforePublish ? { feeRefund: 'due' } : {}),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
});
|
||||
return { status: 'withdrawn', feeRefundTxIds: await settleFeeRefund(campaignId) };
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
||||
import { onSchedule } from 'firebase-functions/v2/scheduler';
|
||||
import {
|
||||
getFirestore, FieldPath, FieldValue, Timestamp, type DocumentSnapshot,
|
||||
} from 'firebase-admin/firestore';
|
||||
import { getStorage } from 'firebase-admin/storage';
|
||||
import { logger } from 'firebase-functions';
|
||||
|
||||
import { callerUid, requireRole, str } from '../core/callable.js';
|
||||
import { CatalogError, ERR, fail } from '../core/errors.js';
|
||||
import { refundPop } from '../ledger/ledger.js';
|
||||
import { retryFeeRefunds, settleFeeRefund } from './create.js';
|
||||
|
||||
/**
|
||||
* 캠페인 정산과 환불 — 별첨 1 ⑤.
|
||||
*
|
||||
* 판정 기준이 「성공/실패 분기 정확 · 자동환불 무결성(**부분 실패 방치·중복 환불
|
||||
* 0건**) · 결과보고 게시 확인」이다. 부분 실패를 방치하지 않는 것이 핵심이라
|
||||
* 실패한 건을 세어 남기고 Admin 큐로 넘긴다.
|
||||
*
|
||||
* 환불 기한은 정책 §P-09 가 **마감 후 72시간**으로 확정했다.
|
||||
*/
|
||||
|
||||
const REFUND_DEADLINE_HOURS = 72;
|
||||
|
||||
/**
|
||||
* 자동 환불 패스 상한. 실패가 남은 패스가 이만큼 쌓이면 Admin Exception Queue 로 넘기고
|
||||
* 자동 재시도를 멈춘다(원장 B-36 「부분 실패는 자동 재시도 후 Admin Exception Queue」).
|
||||
*/
|
||||
export const MAX_AUTO_REFUND_ATTEMPTS = 3;
|
||||
|
||||
/** 한 회차에 처리하는 후원 수. 넘으면 커서를 남기고 다음 회차가 이어 간다. */
|
||||
export const REFUND_PAGE = 200;
|
||||
|
||||
/** 함수 제한 시간과 리스. 리스가 제한 시간보다 길어야 죽은 회차의 잠금이 풀린 뒤에만 겹친다. */
|
||||
const TIMEOUT_SECONDS = 540;
|
||||
const LEASE_MS = (TIMEOUT_SECONDS + 60) * 1000;
|
||||
|
||||
/**
|
||||
* 후원을 돌려주는 캠페인 상태. `fail` 이 규칙·앱의 표기이고 `failed` 는 예전 정산이 쓴
|
||||
* 값이다. `cancelled` 는 회사귀책 취소(B-58 · `fn-campaign-admin-cancel`)다.
|
||||
*/
|
||||
const FAILED = ['fail', 'failed', 'cancelled'];
|
||||
|
||||
/** 규칙·설계서의 스키마 이름은 `targetPop` 이다. `goalPop` 은 옛 이름이라 보조로만 읽는다. */
|
||||
const goalOf = (snap: DocumentSnapshot): number =>
|
||||
(snap.get('targetPop') as number | undefined)
|
||||
?? (snap.get('goalPop') as number | undefined) ?? 0;
|
||||
const raisedOf = (snap: DocumentSnapshot): number =>
|
||||
(snap.get('raisedPop') as number | undefined) ?? 0;
|
||||
|
||||
/**
|
||||
* `fn-campaign-settle` — 마감을 지난 캠페인을 성공·실패로 가르고, 실패면 **같은 회차에서**
|
||||
* 환불을 시작한다(B-36 「즉시 환불 시작」). 부분 실패·미완 캠페인도 여기서 이어 돈다.
|
||||
*
|
||||
* 스케줄을 10분으로 둔 이유는 마감 직후의 대기 시간을 줄이기 위해서다.
|
||||
* 그보다 촘촘히 돌려도 마감 시각이 분 단위라 얻는 것이 없다.
|
||||
*/
|
||||
export const fnCampaignSettle = onSchedule(
|
||||
{ schedule: 'every 10 minutes', timeoutSeconds: TIMEOUT_SECONDS },
|
||||
async () => {
|
||||
const settled = await settleDueCampaigns();
|
||||
const retried = await retryPendingRefunds();
|
||||
const fees = await retryFeeRefunds();
|
||||
logger.info('[campaign] 정산 완료', { settled, retried, fees });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 마감이 지난 `funding` 캠페인 판정. 판정한 건수를 돌려준다.
|
||||
*
|
||||
* **캠페인마다 트랜잭션으로 다시 읽고 판정한다.** 쿼리 결과로 판정하면 그 사이 들어온
|
||||
* 후원이 빠진 모금액으로 성공·실패를 가른다. 후원(`fn-campaign-fund`)도 같은 문서를
|
||||
* 트랜잭션으로 읽고 마감 뒤 후원을 거부하므로 판정 뒤에 모금액이 움직이지 않는다.
|
||||
*/
|
||||
export async function settleDueCampaigns(now: Timestamp = Timestamp.now()): Promise<number> {
|
||||
const db = getFirestore();
|
||||
const due = await db
|
||||
.collection('campaigns')
|
||||
.where('status', '==', 'funding')
|
||||
.where('endsAt', '<=', now)
|
||||
.get();
|
||||
|
||||
let settled = 0;
|
||||
for (const doc of due.docs) {
|
||||
const success = await db.runTransaction(async (tx) => {
|
||||
const snap = await tx.get(doc.ref);
|
||||
const endsAt = snap.get('endsAt') as Timestamp | undefined;
|
||||
if (snap.get('status') !== 'funding' || !endsAt || endsAt.toMillis() > now.toMillis()) {
|
||||
return null;
|
||||
}
|
||||
const goal = goalOf(snap);
|
||||
const ok = goal > 0 && raisedOf(snap) >= goal;
|
||||
tx.update(doc.ref, {
|
||||
// 규칙 `campaignIsPublic()` · 앱과 같은 표기.
|
||||
status: ok ? 'success' : 'fail',
|
||||
settledAt: FieldValue.serverTimestamp(),
|
||||
// 실패면 환불 기한을 함께 박아 둔다. 나중에 계산하면 기준이 흔들린다.
|
||||
...(ok
|
||||
? {}
|
||||
: {
|
||||
refundDueAt: Timestamp.fromMillis(
|
||||
now.toMillis() + REFUND_DEADLINE_HOURS * 60 * 60 * 1000,
|
||||
),
|
||||
refundState: 'pending',
|
||||
refundAttempts: 0,
|
||||
refundPassFailed: 0,
|
||||
}),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
return ok;
|
||||
});
|
||||
if (success === null) continue;
|
||||
settled += 1;
|
||||
logger.info('[campaign] 정산', { id: doc.id, success });
|
||||
}
|
||||
return settled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 환불이 끝나지 않은 실패 캠페인을 돌린다 — 방금 실패로 판정된 건도 여기서 첫 환불을 받는다.
|
||||
* 자동 시도 상한을 넘긴 건은 Admin 큐에 있으므로 건드리지 않는다. 실제로 돈 건수를 돌려준다.
|
||||
*/
|
||||
export async function retryPendingRefunds(): Promise<number> {
|
||||
// 단일 필드 조건이라 복합 색인이 필요 없다. 상태는 아래에서 거른다.
|
||||
const open = await getFirestore()
|
||||
.collection('campaigns')
|
||||
.where('refundState', 'in', ['pending', 'partial'])
|
||||
.get();
|
||||
let ran = 0;
|
||||
for (const doc of open.docs) {
|
||||
if (!FAILED.includes(doc.get('status') as string)) continue;
|
||||
if (((doc.get('refundAttempts') as number | undefined) ?? 0) >= MAX_AUTO_REFUND_ATTEMPTS) continue;
|
||||
const result = await refundCampaign(doc.id, { manual: false });
|
||||
if (!result.skipped) ran += 1;
|
||||
}
|
||||
return ran;
|
||||
}
|
||||
|
||||
type RefundFailure = { donationId: string; error: string };
|
||||
|
||||
export interface RefundResult {
|
||||
/** 이 회차에 새로 돌려준 건수. */
|
||||
refunded: number;
|
||||
failed: RefundFailure[];
|
||||
/** 한 바퀴를 다 돌았는가. `false` 면 커서가 남아 다음 회차가 이어 간다. */
|
||||
done: boolean;
|
||||
/** 돌지 않은 이유 — 다른 회차가 잡고 있거나, 옛 정산이 실패로 잘못 적은 캠페인. */
|
||||
skipped?: 'locked' | 'legacy-goal-met';
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 캠페인 환불 한 회차. 자동(스케줄)과 수동(Admin) 이 같은 코어를 지난다.
|
||||
*
|
||||
* - **리스** — `lockedUntil` 을 트랜잭션으로 잡은 회차만 돈다. 겹친 회차는 건너뛴다.
|
||||
* - **분할** — 한 회차에 [REFUND_PAGE] 건. 커서(`refundCursor`)와 이번 바퀴의 실패 수
|
||||
* (`refundPassFailed`)를 매 회차 남긴다.
|
||||
* - **중복 환불 0건** — 후원 한 건의 멱등 키가 `refund:{donationId}` 하나다. 다른 경로
|
||||
* (`fn-pop-refund`)가 먼저 돌려준 원거래는 그 환불을 받아들여 끝난 건으로 본다.
|
||||
* - **부분 실패를 성공으로 덮지 않는다.** 실패가 남은 바퀴는 `partial` 로 남기고 자동 시도를
|
||||
* 센다(`refundAttempts`). 상한에 닿거나 Admin 이 돌려도 남으면 Exception Queue
|
||||
* (`adminActionLogs`)에 적는다(E-CMP-02).
|
||||
* - **옛 데이터** — `refundAttempts` 가 없는 문서는 goalPop 만 읽던 예전 정산이 판정했다.
|
||||
* `targetPop` 으로 다시 판정해 목표를 채운 캠페인은 돌려주지 않는다.
|
||||
*/
|
||||
export async function refundCampaign(
|
||||
campaignId: string,
|
||||
opts: { manual: boolean },
|
||||
): Promise<RefundResult> {
|
||||
const db = getFirestore();
|
||||
const campaignRef = db.doc(`campaigns/${campaignId}`);
|
||||
|
||||
const lease = await db.runTransaction(async (tx) => {
|
||||
const snap = await tx.get(campaignRef);
|
||||
if (!snap.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
|
||||
if (!FAILED.includes(snap.get('status') as string)) {
|
||||
fail(ERR.campaignNotApproved, '실패하거나 취소된 캠페인만 환불할 수 있습니다.');
|
||||
}
|
||||
const locked = snap.get('lockedUntil') as Timestamp | undefined;
|
||||
if (locked && locked.toMillis() > Date.now()) return { skipped: 'locked' as const };
|
||||
const legacy = typeof snap.get('refundAttempts') !== 'number';
|
||||
if (legacy) {
|
||||
const goal = goalOf(snap);
|
||||
if (goal > 0 && raisedOf(snap) >= goal) return { skipped: 'legacy-goal-met' as const };
|
||||
}
|
||||
tx.update(campaignRef, {
|
||||
lockedUntil: Timestamp.fromMillis(Date.now() + LEASE_MS),
|
||||
// 다시 판정해도 실패인 옛 문서는 지금 표기로 맞춘다.
|
||||
...(legacy ? { status: 'fail', refundAttempts: 0, refundPassFailed: 0 } : {}),
|
||||
});
|
||||
return {
|
||||
title: (snap.get('title') as string | undefined) ?? '캠페인',
|
||||
cancelled: snap.get('status') === 'cancelled',
|
||||
cursor: (snap.get('refundCursor') as string | undefined) ?? null,
|
||||
attempts: legacy ? 0 : (snap.get('refundAttempts') as number),
|
||||
passFailed: legacy ? 0 : ((snap.get('refundPassFailed') as number | undefined) ?? 0),
|
||||
};
|
||||
});
|
||||
if ('skipped' in lease) {
|
||||
logger.warn('[campaign] 환불 건너뜀', { campaignId, reason: lease.skipped });
|
||||
return { refunded: 0, failed: [], done: false, skipped: lease.skipped };
|
||||
}
|
||||
|
||||
let query = campaignRef.collection('donations')
|
||||
.orderBy(FieldPath.documentId())
|
||||
.limit(REFUND_PAGE);
|
||||
if (lease.cursor) query = query.startAfter(lease.cursor);
|
||||
const page = await query.get();
|
||||
|
||||
const failed: RefundFailure[] = [];
|
||||
let refunded = 0;
|
||||
for (const donation of page.docs) {
|
||||
const uid = donation.get('uid') as string | undefined;
|
||||
let refundTxId = donation.get('refundTxId') as string | undefined;
|
||||
if (!refundTxId) {
|
||||
const txId = donation.get('txId') as string | undefined;
|
||||
if (!uid || !txId) {
|
||||
failed.push({ donationId: donation.id, error: 'missing-uid-or-tx' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
refundTxId = (await refundPop({
|
||||
uid,
|
||||
originalTxId: txId,
|
||||
// 후원 한 건당 한 번. 재실행해도 두 번 돌려주지 않는다.
|
||||
idempotencyKey: `refund:${donation.id}`,
|
||||
reasonCode: 'CAMPAIGN_REFUND',
|
||||
})).txId;
|
||||
} catch (error) {
|
||||
if (error instanceof CatalogError && error.catalogCode === ERR.popDuplicate) {
|
||||
// 다른 경로가 이미 돌려준 원거래 — 그 환불을 이 후원의 환불로 받아들인다.
|
||||
refundTxId = (await db.doc(`users/${uid}/popTransactions/${txId}`).get())
|
||||
.get('refundedBy') as string;
|
||||
} else {
|
||||
failed.push({ donationId: donation.id, error: String(error) });
|
||||
await donation.ref.set({ refundError: String(error) }, { merge: true });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await donation.ref.set({
|
||||
refundTxId,
|
||||
refundedAt: FieldValue.serverTimestamp(),
|
||||
refundError: FieldValue.delete(),
|
||||
}, { merge: true });
|
||||
refunded += 1;
|
||||
}
|
||||
// 환불과 알림 사이에 죽어도 다음 회차가 알림만 다시 보낸다(문서 ID = 환불 거래).
|
||||
if (uid && !donation.get('refundNotifiedAt')) {
|
||||
await notifyOnce(uid, `campaignRefund_${refundTxId}`, {
|
||||
title: lease.cancelled
|
||||
? `「${lease.title}」 캠페인이 취소되어 후원 POP 을 돌려드렸습니다`
|
||||
: `「${lease.title}」 목표 미달로 후원 POP 을 돌려드렸습니다`,
|
||||
campaignId,
|
||||
});
|
||||
await donation.ref.set({ refundNotifiedAt: FieldValue.serverTimestamp() }, { merge: true });
|
||||
}
|
||||
}
|
||||
|
||||
const passFailed = lease.passFailed + failed.length;
|
||||
if (page.size === REFUND_PAGE) {
|
||||
// 아직 남았다 — 진행 상황만 남기고 잠금을 푼다. 다음 회차가 커서부터 잇는다.
|
||||
await campaignRef.update({
|
||||
refundCursor: page.docs[page.docs.length - 1]!.id,
|
||||
refundPassFailed: passFailed,
|
||||
refundLastRunAt: FieldValue.serverTimestamp(),
|
||||
lockedUntil: FieldValue.delete(),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
return { refunded, failed, done: false };
|
||||
}
|
||||
|
||||
const counted = passFailed > 0 && !opts.manual ? 1 : 0;
|
||||
const toQueue = passFailed > 0
|
||||
&& (opts.manual || lease.attempts + counted >= MAX_AUTO_REFUND_ATTEMPTS);
|
||||
const refundedCount = (await campaignRef.collection('donations')
|
||||
.where('refundTxId', '!=', null).count().get()).data().count;
|
||||
await campaignRef.update({
|
||||
refundState: passFailed === 0 ? 'done' : 'partial',
|
||||
refundedCount,
|
||||
refundFailedCount: passFailed,
|
||||
refundAttempts: FieldValue.increment(counted),
|
||||
refundCursor: FieldValue.delete(),
|
||||
refundPassFailed: 0,
|
||||
refundLastRunAt: FieldValue.serverTimestamp(),
|
||||
lockedUntil: FieldValue.delete(),
|
||||
...(passFailed === 0 ? { refundedAt: FieldValue.serverTimestamp() } : {}),
|
||||
...(toQueue ? { refundQueuedAt: FieldValue.serverTimestamp() } : {}),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
if (toQueue) {
|
||||
await db.collection('adminActionLogs').add({
|
||||
actorUid: 'system',
|
||||
targetPath: campaignRef.path,
|
||||
reason: '캠페인 환불 부분 실패 — 수동 복구 필요',
|
||||
// 건별 사유는 후원 문서의 `refundError` 에 남아 있다.
|
||||
patch: { failedCount: passFailed, attempts: lease.attempts + counted, lastRun: failed },
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
logger.error('[campaign] 환불 부분 실패 — Admin 큐', { campaignId, passFailed });
|
||||
} else if (passFailed > 0) {
|
||||
logger.warn('[campaign] 환불 부분 실패 — 자동 재시도 예정', { campaignId, passFailed });
|
||||
}
|
||||
|
||||
return { refunded, failed, done: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림함 한 건. 문서 ID 를 고정해 재실행이 같은 알림을 두 번 쌓지 않는다.
|
||||
* `type` 은 B-77 수신 설정 축, `eventType` 은 알림함 이동 축이다(앱 `NotificationKind`).
|
||||
*/
|
||||
async function notifyOnce(
|
||||
uid: string,
|
||||
id: string,
|
||||
data: { title: string; campaignId: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
await getFirestore().doc(`users/${uid}/notifications/${id}`).create({
|
||||
type: 'essential',
|
||||
eventType: 'campaign',
|
||||
...data,
|
||||
read: false,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
} catch (error) {
|
||||
// 이미 보낸 알림(ALREADY_EXISTS = 6)은 정상이다. 그 밖의 실패도 환불을 되돌리지 않는다.
|
||||
if ((error as { code?: number }).code !== 6) {
|
||||
logger.error('[campaign] 알림 기록 실패', { uid, id, message: (error as Error).message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `fn-campaign-refund-batch` — Admin 수동 복구. 환불은 정산이 자동으로 시작하므로
|
||||
* 이 호출은 Exception Queue 에 오른 건을 다시 돌리는 경로다. `done: false` 면 다시 부른다.
|
||||
*/
|
||||
export const fnCampaignRefundBatch = onCall({ timeoutSeconds: TIMEOUT_SECONDS }, async (req) => {
|
||||
callerUid(req);
|
||||
requireRole(req, ['master', 'ops']);
|
||||
return refundCampaign(str(req.data, 'campaignId'), { manual: true });
|
||||
});
|
||||
|
||||
/** 결과보고 사진·영수증 상한 — 원장 B-44 「이미지 5장 · 10MB」. 용량·형식은 Storage 규칙이 본다. */
|
||||
export const REPORT_MAX_PHOTOS = 5;
|
||||
const REPORT_MAX_CHARS = 5000;
|
||||
const BUDGET_MAX_LINES = 50;
|
||||
const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const CAMPAIGN_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
|
||||
export interface BudgetLine { label: string; amount: number }
|
||||
|
||||
/**
|
||||
* `fn-campaign-report-submit` — 성공 캠페인 결과보고(원장 B-35 · FR-ADM-05 · FR-CMP-06).
|
||||
*
|
||||
* **운영(master·ops)만 올린다.** B-35 는 funded 이후 집행·증빙·결과보고를 Admin 수동 운영으로
|
||||
* 두었다. 집행 사진 · 영수증 · 예산 사용 내역을 함께 받아 보고와 상태 `report` 를 한
|
||||
* 트랜잭션에 쓰고, 후원자 전원의 알림함에 안내를 남긴다.
|
||||
*
|
||||
* 파일은 URL 이 아니라 **Storage 경로**로 받는다. 경로가 정확히 `campaignReports/{id}/{파일}`
|
||||
* 이고(상위 이동 · 하위 폴더 없음) 실제로 올라가 있어야 한다.
|
||||
*/
|
||||
export const fnCampaignReportSubmit = onCall({ timeoutSeconds: 120 }, async (req) => {
|
||||
const uid = callerUid(req);
|
||||
requireRole(req, ['master', 'ops']);
|
||||
const data = req.data as Record<string, unknown> | null;
|
||||
return submitCampaignReport({
|
||||
uid,
|
||||
campaignId: str(data, 'campaignId'),
|
||||
body: str(data, 'body'),
|
||||
photoPaths: data?.photoPaths,
|
||||
receiptPaths: data?.receiptPaths,
|
||||
budget: data?.budget,
|
||||
});
|
||||
});
|
||||
|
||||
const storageExists = async (path: string): Promise<boolean> =>
|
||||
(await getStorage().bucket().file(path).exists())[0];
|
||||
|
||||
function reportPaths(raw: unknown, campaignId: string, what: string): string[] {
|
||||
const prefix = `campaignReports/${campaignId}/`;
|
||||
const ok = Array.isArray(raw) && raw.length >= 1 && raw.length <= REPORT_MAX_PHOTOS
|
||||
&& new Set(raw).size === raw.length
|
||||
&& raw.every((p) => typeof p === 'string' && p.startsWith(prefix)
|
||||
&& FILE_NAME.test(p.slice(prefix.length)));
|
||||
if (!ok) throw new HttpsError('invalid-argument', `${what}을 1~${REPORT_MAX_PHOTOS}장 올려 주세요.`);
|
||||
return raw as string[];
|
||||
}
|
||||
|
||||
function budgetLines(raw: unknown): BudgetLine[] {
|
||||
const ok = Array.isArray(raw) && raw.length >= 1 && raw.length <= BUDGET_MAX_LINES
|
||||
&& raw.every((l) => typeof l?.label === 'string'
|
||||
&& l.label.trim().length >= 1 && l.label.trim().length <= 100
|
||||
&& Number.isSafeInteger(l.amount) && l.amount > 0);
|
||||
if (!ok) {
|
||||
throw new HttpsError('invalid-argument', `예산 사용 내역을 1~${BUDGET_MAX_LINES}줄(항목 · 금액) 적어 주세요.`);
|
||||
}
|
||||
return (raw as BudgetLine[]).map((l) => ({ label: l.label.trim(), amount: l.amount }));
|
||||
}
|
||||
|
||||
export async function submitCampaignReport(
|
||||
input: {
|
||||
uid: string; campaignId: string; body: string;
|
||||
photoPaths: unknown; receiptPaths: unknown; budget: unknown;
|
||||
},
|
||||
exists: (path: string) => Promise<boolean> = storageExists,
|
||||
): Promise<{ duplicate: boolean }> {
|
||||
const { uid, campaignId } = input;
|
||||
if (!CAMPAIGN_ID.test(campaignId)) throw new HttpsError('invalid-argument', 'campaignId 가 올바르지 않습니다.');
|
||||
const body = input.body.trim();
|
||||
if (body.length === 0 || body.length > REPORT_MAX_CHARS) {
|
||||
throw new HttpsError('invalid-argument', `본문은 1~${REPORT_MAX_CHARS}자입니다.`);
|
||||
}
|
||||
const photoPaths = reportPaths(input.photoPaths, campaignId, '집행 사진');
|
||||
const receiptPaths = reportPaths(input.receiptPaths, campaignId, '영수증');
|
||||
const budget = budgetLines(input.budget);
|
||||
for (const path of [...photoPaths, ...receiptPaths]) {
|
||||
if (!(await exists(path))) throw new HttpsError('invalid-argument', '올라가지 않은 파일이 있습니다.');
|
||||
}
|
||||
|
||||
const db = getFirestore();
|
||||
const campaignRef = db.doc(`campaigns/${campaignId}`);
|
||||
const reportRef = campaignRef.collection('reports').doc('main');
|
||||
const duplicate = await db.runTransaction(async (tx) => {
|
||||
const campaign = await tx.get(campaignRef);
|
||||
if (!campaign.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
|
||||
if (campaign.get('status') === 'report') return true;
|
||||
if (campaign.get('status') !== 'success') {
|
||||
fail(ERR.campaignNotApproved, '성공한 캠페인만 결과보고를 올릴 수 있습니다.');
|
||||
}
|
||||
tx.create(reportRef, {
|
||||
authorUid: uid,
|
||||
body,
|
||||
photoPaths,
|
||||
receiptPaths,
|
||||
budget,
|
||||
budgetTotal: budget.reduce((sum, l) => sum + l.amount, 0),
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
tx.update(campaignRef, {
|
||||
status: 'report',
|
||||
reportedAt: FieldValue.serverTimestamp(),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
// 게시와 알림 사이에 죽었으면 재호출이 알림만 다시 보낸다(문서 ID = 보고 ID).
|
||||
const report = await reportRef.get();
|
||||
if (!report.get('notifiedAt')) {
|
||||
const title = ((await campaignRef.get()).get('title') as string | undefined) ?? '캠페인';
|
||||
const donations = await campaignRef.collection('donations').get();
|
||||
const donors = new Set(donations.docs
|
||||
.map((d) => d.get('uid') as string | undefined)
|
||||
.filter((u): u is string => !!u));
|
||||
for (const donor of donors) {
|
||||
await notifyOnce(donor, `campaignReport_${campaignId}_${reportRef.id}`, {
|
||||
title: `후원하신 「${title}」 결과보고가 올라왔습니다`,
|
||||
campaignId,
|
||||
});
|
||||
}
|
||||
await reportRef.update({ notifiedAt: FieldValue.serverTimestamp() });
|
||||
}
|
||||
return { duplicate };
|
||||
}
|
||||
|
||||
/**
|
||||
* `fn-campaign-admin-review` — 승인·반려.
|
||||
*
|
||||
* **승인 전에는 모금이 열리지 않는다**(`fn-campaign-fund` 가 E-CMP-03 로 막는다).
|
||||
* 여기서 `funding` 으로 올려야 후원이 시작된다. 반려는 등록비를 원래 타입으로 돌려준다
|
||||
* (B-58). 반환이 중간에 실패하면 같은 요청의 재시도가 마저 한다.
|
||||
*/
|
||||
export const fnCampaignAdminReview = onCall(async (req) => {
|
||||
const actorUid = callerUid(req);
|
||||
requireRole(req, ['master', 'ops']);
|
||||
const campaignId = str(req.data, 'campaignId');
|
||||
const requestId = str(req.data, 'requestId');
|
||||
const approve = (req.data as Record<string, unknown>)?.approve === true;
|
||||
const memo = str(req.data, 'memo');
|
||||
|
||||
const db = getFirestore();
|
||||
const seen = db.doc(`campaigns/${campaignId}/reviews/${requestId}`);
|
||||
const campaignRef = db.doc(`campaigns/${campaignId}`);
|
||||
// 앞선 반려의 반환이 중간에 끊겼으면 여기서 마저 한다 — 상태 판정보다 먼저.
|
||||
await settleFeeRefund(campaignId);
|
||||
|
||||
// 판정과 전이를 한 트랜잭션에 둔다. 개설자 철회(`fn-campaign-withdraw`)도 같은 문서를
|
||||
// 트랜잭션으로 읽으므로 승인과 철회가 함께 서지 않는다.
|
||||
const result = await db.runTransaction(async (tx) => {
|
||||
const [review, campaign] = await Promise.all([tx.get(seen), tx.get(campaignRef)]);
|
||||
// 같은 요청이 두 번 들어와도 판정을 뒤집지 않는다.
|
||||
if (review.exists) return { duplicate: true };
|
||||
if (!campaign.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
|
||||
if (campaign.get('status') !== 'review') {
|
||||
fail(ERR.campaignNotApproved, '검토 대기 상태가 아닙니다.');
|
||||
}
|
||||
tx.update(campaignRef, {
|
||||
status: approve ? 'funding' : 'rejected',
|
||||
// 반려는 등록비 반환(B-58). 전이와 같은 쓰기에 표시해 두면 반환이 끊겨도 스케줄이 잇는다.
|
||||
...(approve ? {} : { feeRefund: 'due' }),
|
||||
reviewedBy: actorUid,
|
||||
reviewMemo: memo,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
tx.create(seen, { actorUid, approve, memo, createdAt: FieldValue.serverTimestamp() });
|
||||
return { duplicate: false, status: approve ? 'funding' : 'rejected' };
|
||||
});
|
||||
if (!approve) await settleFeeRefund(campaignId);
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* `fn-campaign-admin-cancel` — 회사귀책 취소(B-58).
|
||||
*
|
||||
* 심사 중 · 모금 중 캠페인을 `cancelled` 로 닫고 **등록비와 후원 전액**을 돌려준다.
|
||||
* 후원 환불은 실패 캠페인과 같은 코어(`refundCampaign` — 건당 멱등 · 분할 · 부분 실패 큐)를
|
||||
* 지나며, 한 회차에 끝나지 않으면 `fn-campaign-settle` 이 이어 돈다. 개설자에게 알린다.
|
||||
* 이미 취소된 캠페인에 다시 부르면 남은 반환만 이어 간다.
|
||||
*/
|
||||
export const fnCampaignAdminCancel = onCall({ timeoutSeconds: TIMEOUT_SECONDS }, async (req) => {
|
||||
const actorUid = callerUid(req);
|
||||
requireRole(req, ['master', 'ops']);
|
||||
return cancelCampaign(str(req.data, 'campaignId'), actorUid, str(req.data, 'reason'));
|
||||
});
|
||||
|
||||
export async function cancelCampaign(
|
||||
campaignId: string, actorUid: string, reason: string,
|
||||
): Promise<{ status: 'cancelled'; refund: RefundResult }> {
|
||||
const db = getFirestore();
|
||||
const campaignRef = db.doc(`campaigns/${campaignId}`);
|
||||
const creator = await db.runTransaction(async (tx) => {
|
||||
const snap = await tx.get(campaignRef);
|
||||
if (!snap.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
|
||||
const status = snap.get('status') as string | undefined;
|
||||
if (status !== 'cancelled') {
|
||||
if (status !== 'review' && status !== 'funding') {
|
||||
fail(ERR.campaignNotApproved, '심사 중이거나 모금 중인 캠페인만 취소할 수 있습니다.');
|
||||
}
|
||||
tx.update(campaignRef, {
|
||||
status: 'cancelled',
|
||||
cancelReason: reason,
|
||||
cancelledBy: actorUid,
|
||||
cancelledAt: FieldValue.serverTimestamp(),
|
||||
feeRefund: 'due',
|
||||
refundState: 'pending',
|
||||
refundAttempts: 0,
|
||||
refundPassFailed: 0,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
}
|
||||
return {
|
||||
uid: snap.get('creatorUid') as string | undefined,
|
||||
title: (snap.get('title') as string | undefined) ?? '캠페인',
|
||||
};
|
||||
});
|
||||
await settleFeeRefund(campaignId);
|
||||
if (creator.uid) {
|
||||
await notifyOnce(creator.uid, `campaignCancel_${campaignId}`, {
|
||||
title: `「${creator.title}」 캠페인이 운영 사유로 취소되어 등록비를 돌려드렸습니다`,
|
||||
campaignId,
|
||||
});
|
||||
}
|
||||
return { status: 'cancelled', refund: await refundCampaign(campaignId, { manual: false }) };
|
||||
}
|
||||
Reference in New Issue
Block a user