Skip to content
12 changes: 12 additions & 0 deletions Keychy/Keychy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,9 @@
4C86A6122F25C0B10023AA2D /* WorkshopBundleGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A6112F25C0B10023AA2D /* WorkshopBundleGridView.swift */; };
4C86A6142F25C0BA0023AA2D /* WorkshopKeyringGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A6132F25C0BA0023AA2D /* WorkshopKeyringGridView.swift */; };
4C86A6182F276CFD0023AA2D /* WorkshopTemplateSelectSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A6172F276CFD0023AA2D /* WorkshopTemplateSelectSheet.swift */; };
4C86A61B2F29D7C60023AA2D /* Receipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A61A2F29D7C60023AA2D /* Receipt.swift */; };
4C86A61D2F29E1FE0023AA2D /* PurchaseHistoryVIewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A61C2F29E1FE0023AA2D /* PurchaseHistoryVIewModel.swift */; };
4C86A61F2F29E52D0023AA2D /* PurchaseHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A61E2F29E52D0023AA2D /* PurchaseHistoryView.swift */; };
4CA9C6A62EC9D11600CA546B /* CustomNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9C6A42EC9D11600CA546B /* CustomNavigationBar.swift */; };
4CA9C6A82EC9DB5300CA546B /* View+SafeAreaBottom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9C6A72EC9DB5300CA546B /* View+SafeAreaBottom.swift */; };
4CA9C6D62ECB7AEA00CA546B /* BadWords.json in Resources */ = {isa = PBXBuildFile; fileRef = 4CA9C6D52ECB7AEA00CA546B /* BadWords.json */; };
Expand Down Expand Up @@ -689,6 +692,9 @@
4C86A6112F25C0B10023AA2D /* WorkshopBundleGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopBundleGridView.swift; sourceTree = "<group>"; };
4C86A6132F25C0BA0023AA2D /* WorkshopKeyringGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopKeyringGridView.swift; sourceTree = "<group>"; };
4C86A6172F276CFD0023AA2D /* WorkshopTemplateSelectSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopTemplateSelectSheet.swift; sourceTree = "<group>"; };
4C86A61A2F29D7C60023AA2D /* Receipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Receipt.swift; sourceTree = "<group>"; };
4C86A61C2F29E1FE0023AA2D /* PurchaseHistoryVIewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseHistoryVIewModel.swift; sourceTree = "<group>"; };
4C86A61E2F29E52D0023AA2D /* PurchaseHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseHistoryView.swift; sourceTree = "<group>"; };
4CA9C6A42EC9D11600CA546B /* CustomNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomNavigationBar.swift; sourceTree = "<group>"; };
4CA9C6A72EC9DB5300CA546B /* View+SafeAreaBottom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+SafeAreaBottom.swift"; sourceTree = "<group>"; };
4CA9C6D52ECB7AEA00CA546B /* BadWords.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = BadWords.json; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1010,6 +1016,7 @@
38A596722EAFA8D20003D712 /* KeychyUser.swift */,
4CA9C6F62ECBA45200CA546B /* KeychyNotification.swift */,
382800D02EC05D4E005F1332 /* PostOffice.swift */,
4C86A61A2F29D7C60023AA2D /* Receipt.swift */,
);
path = User;
sourceTree = "<group>";
Expand Down Expand Up @@ -1479,6 +1486,7 @@
children = (
C6EE7AD62EB445F6002B5669 /* MyPageView.swift */,
4C65306B2EBC889B000F8154 /* ChangeNameView.swift */,
4C86A61E2F29E52D0023AA2D /* PurchaseHistoryView.swift */,
4C65306D2EBCF157000F8154 /* TermsView.swift */,
);
path = MyPage;
Expand Down Expand Up @@ -1994,6 +2002,7 @@
4CC8D0242EF11CD200317467 /* HomeViewModel.swift */,
4CC8D01E2EF0447100317467 /* MyPageViewModel.swift */,
4CC8D01D2EF0447100317467 /* ChangeNameViewModel.swift */,
4C86A61C2F29E1FE0023AA2D /* PurchaseHistoryVIewModel.swift */,
4CC8D00A2EEFC36900317467 /* AlarmViewModel.swift */,
4CC8D0212EF110A300317467 /* NotificationGiftViewModel.swift */,
);
Expand Down Expand Up @@ -2555,6 +2564,7 @@
4C4733AD2F1FA388005D2376 /* ClearSketchVM+ImageConversion.swift in Sources */,
4C4733AE2F1FA388005D2376 /* CinematicAppearanceModifier.swift in Sources */,
4C4733AF2F1FA388005D2376 /* ClearSketchVM+Effect.swift in Sources */,
4C86A61B2F29D7C60023AA2D /* Receipt.swift in Sources */,
4C4733B02F1FA388005D2376 /* AcrylicPhotoVM+Effect.swift in Sources */,
4C4733B12F1FA388005D2376 /* PixelVM.swift in Sources */,
4C4733B22F1FA388005D2376 /* KeyringCompleteView+ReviewCheck.swift in Sources */,
Expand Down Expand Up @@ -2660,6 +2670,7 @@
4C07024C2ECF10760026D6DC /* EffectSyncManager.swift in Sources */,
3828F54B2EC4D0C500F1B040 /* CollectionView+Handlers.swift in Sources */,
4CF2A96A2F0B94EA00BA9FDA /* View+PullToRefresh.swift in Sources */,
4C86A61F2F29E52D0023AA2D /* PurchaseHistoryView.swift in Sources */,
38C147BB2EB13B2F00A8E511 /* CircleGlassButton.swift in Sources */,
AA6298542EC39065001576C0 /* BundleCreateView.swift in Sources */,
38C147C72EB1F57F00A8E511 /* StorageManager.swift in Sources */,
Expand Down Expand Up @@ -2696,6 +2707,7 @@
C645AEA32EB1B8FC004BFE69 /* DataInitializer.swift in Sources */,
4CA9C6F72ECBA45200CA546B /* KeychyNotification.swift in Sources */,
4CF2A9682F0B91F300BA9FDA /* AnimatedGIFView.swift in Sources */,
4C86A61D2F29E1FE0023AA2D /* PurchaseHistoryVIewModel.swift in Sources */,
4CC8D01F2EF0447100317467 /* ChangeNameViewModel.swift in Sources */,
4CC8D0202EF0447100317467 /* MyPageViewModel.swift in Sources */,
4C4734072F226B81005D2376 /* WorkshopMakeMenu.swift in Sources */,
Expand Down
37 changes: 37 additions & 0 deletions Keychy/Keychy/CommonModels/User/Receipt.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// Receipt.swift
// Keychy
//
// Created by 길지훈 on 1/28/26.
//

import Foundation
import FirebaseFirestore

struct Receipt: Identifiable, Codable {
@DocumentID var id: String?
var itemID: String
var itemName: String
var itemType: String
var price: Int
var purchasedAt: Date

/// 아이템 타입 표시명
var itemTypeDisplayName: String {
switch itemType {
case "template": return "템플릿"
case "background": return "배경"
case "carabiner": return "카라비너"
case "particle": return "파티클"
case "sound": return "사운드"
default: return itemType
}
}

/// 구매일시 포맷 (2026-01-01 22:22:22)
var purchasedAtFormatted: String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter.string(from: purchasedAt)
}
}
13 changes: 4 additions & 9 deletions Keychy/Keychy/Core/Components/View/Popup/PurchasePopup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,28 +35,23 @@ struct PurchasePopup: View {
Text("\(myCoin)")
.typography(.nanum16EB)
.foregroundColor(.main500)
.padding(.top, 3)
}
.padding(.bottom, 4)

// 버튼
Button(action: onConfirm) {
HStack(spacing: 4) {
Image(.myCoinMini)
.resizable()
.frame(width: 34, height: 34)
.padding(.bottom, 4)

Text("\(price)")
.typography(.nanum18EB)
.foregroundColor(.white100)
.frame(height: 32)
.padding(.top, 4)

}

.padding(.top, 2)
}
}
.frame(maxWidth: .infinity)
.frame(height: 48)
.padding(.vertical, 12.5)
.background(
RoundedRectangle(cornerRadius: 100)
.fill(.black80)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ struct Typography {
static let suit12R25 = Typography(font: .custom(.suitRegular, size: 12), lineSpacing: 13)

/// 10
static let suit10R = Typography(font: .custom(.suitRegular, size: 10), lineSpacing: 0)
static let suit10SB = Typography(font: .custom(.suitSemiBold, size: 10), lineSpacing: 0)

/// 9
Expand Down
154 changes: 105 additions & 49 deletions Keychy/Keychy/Core/Firebase/ItemPurchaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,76 +41,132 @@ class ItemPurchaseManager {

private init() {}

// MARK: - Public Methods
/// 워크샵 아이템 구매 처리
/// - Parameters:
/// - item: 구매할 아이템 (WorkshopItem 프로토콜 준수)
/// - userManager: UserManager 인스턴스
/// - Returns: PurchaseResult (성공, 코인부족, 실패)
func purchaseWorkshopItem(_ item: any WorkshopItem, userManager: UserManager) async -> PurchaseResult {
// 1. 현재 유저 정보 확인
guard let userId = userManager.currentUser?.id,
let userCoins = userManager.currentUser?.coin,
let itemId = item.id else {
// 1. 유저/아이템 정보 검증
guard let purchaseInfo = validatePurchaseInfo(item: item, userManager: userManager) else {
return .failed("사용자 정보를 찾을 수 없습니다")
}

// 2. 재화 충분한지 확인
guard userCoins >= item.workshopPrice else {
// 2. 로컬 코인 확인
guard purchaseInfo.userCoins >= item.workshopPrice else {
return .insufficientCoins
}

// 3. Firebase 업데이트
let db = Firestore.firestore()
let userRef = db.collection("User").document(userId)
// 3. Firebase 구매 처리
let userRef = Firestore.firestore().collection("User").document(purchaseInfo.userId)

do {
// 현재 문서 읽기
let snapshot = try await userRef.getDocument()

guard let data = snapshot.data() else {
return .failed("사용자 정보를 찾을 수 없습니다")
}

let currentCoin = data["coin"] as? Int ?? 0

// 재화 재확인
// 서버 코인 확인 & 업데이트
let currentCoin = try await fetchCurrentCoin(userRef: userRef)
guard currentCoin >= item.workshopPrice else {
return .insufficientCoins
}

// 업데이트할 데이터 준비
var updateData: [String: Any] = [
"coin": currentCoin - item.workshopPrice
]

// 아이템 타입에 따라 해당 필드에 추가
if item is KeyringTemplate {
updateData["templates"] = FieldValue.arrayUnion([itemId])
} else if item is Background {
updateData["backgrounds"] = FieldValue.arrayUnion([itemId])
} else if item is Carabiner {
updateData["carabiners"] = FieldValue.arrayUnion([itemId])
} else if item is Particle {
updateData["particleEffects"] = FieldValue.arrayUnion([itemId])
} else if item is Sound {
updateData["soundEffects"] = FieldValue.arrayUnion([itemId])
}

// Firebase 업데이트
// 코인 차감 & 아이템 추가
let updateData = buildUpdateData(item: item, itemId: purchaseInfo.itemId, currentCoin: currentCoin)
try await userRef.updateData(updateData)

// 4. UserManager 데이터 갱신
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
userManager.loadUserInfo(uid: userId) { _ in
continuation.resume()
}
}
// 구매내역 저장
try await saveReceipt(item: item, itemId: purchaseInfo.itemId, userRef: userRef)

// UserManager 갱신
await refreshUserData(userId: purchaseInfo.userId, userManager: userManager)

return .success

} catch {
print("구매 실패 에러: \(error.localizedDescription)")
print("구매 실패: \(error.localizedDescription)")
return .failed("구매 처리 중 오류가 발생했습니다")
}
}

// MARK: - Private Methods
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 좀 더 보기 좋게 잘 쪼개졌네요

/// 구매에 필요한 정보 검증
private func validatePurchaseInfo(
item: any WorkshopItem,
userManager: UserManager
) -> (userId: String, userCoins: Int, itemId: String)? {
guard let userId = userManager.currentUser?.id,
let userCoins = userManager.currentUser?.coin,
let itemId = item.id else {
return nil
}
return (userId, userCoins, itemId)
}

/// 서버에서 현재 코인 조회
private func fetchCurrentCoin(userRef: DocumentReference) async throws -> Int {
let snapshot = try await userRef.getDocument()
guard let data = snapshot.data() else {
throw ItemPurchaseError.userNotFound
}
return data["coin"] as? Int ?? 0
}

/// 업데이트 데이터 생성 (코인 차감 + 아이템 추가)
private func buildUpdateData(
item: any WorkshopItem,
itemId: String,
currentCoin: Int
) -> [String: Any] {
var updateData: [String: Any] = [
"coin": currentCoin - item.workshopPrice
]

let fieldName = itemFieldName(for: item)
updateData[fieldName] = FieldValue.arrayUnion([itemId])

return updateData
}

/// 아이템 타입에 해당하는 Firestore 필드명
private func itemFieldName(for item: any WorkshopItem) -> String {
switch item {
case is KeyringTemplate: return "templates"
case is Background: return "backgrounds"
case is Carabiner: return "carabiners"
case is Particle: return "particleEffects"
case is Sound: return "soundEffects"
default: return "unknown"
}
}

/// 아이템 타입 문자열 (Receipt용)
private func itemTypeName(for item: any WorkshopItem) -> String {
switch item {
case is KeyringTemplate: return "template"
case is Background: return "background"
case is Carabiner: return "carabiner"
case is Particle: return "particle"
case is Sound: return "sound"
default: return "unknown"
}
}

/// 구매내역(Receipt) 저장
private func saveReceipt(
item: any WorkshopItem,
itemId: String,
userRef: DocumentReference
) async throws {
let receiptData: [String: Any] = [
"itemID": itemId,
"itemName": item.name,
"itemType": itemTypeName(for: item),
"price": item.workshopPrice,
"purchasedAt": Timestamp(date: Date())
]
try await userRef.collection("Receipts").addDocument(data: receiptData)
}

/// UserManager 데이터 갱신
private func refreshUserData(userId: String, userManager: UserManager) async {
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
userManager.loadUserInfo(uid: userId) { _ in
continuation.resume()
}
}
}
}
1 change: 1 addition & 0 deletions Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ enum HomeRoute: Hashable, BundleRoute {
case coinCharge
case myPageView
case changeName
case purchaseHistory
case alarmView
case notificationGiftView(postOfficeId: String)
case introView
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// PurchaseHistoryVIewModel.swift
// Keychy
//
// Created by 길지훈 on 1/28/26.
//

import SwiftUI
import FirebaseFirestore

@Observable
class PurchaseHistoryViewModel {
var receipts: [Receipt] = []

private let db = Firestore.firestore()

/// 구매내역 가져오기
func fetchReceipts(userId: String) async {
do {
let snapshot = try await db
.collection("User")
.document(userId)
.collection("Receipts")
.order(by: "purchasedAt", descending: true)
.getDocuments()

receipts = snapshot.documents.compactMap { doc in
/// 각 Firebase 문서를 Codable 모델로 변환, Receipt 타입으로 변환
try? doc.data(as: Receipt.self)
}

} catch {
print("구매내역 로드 실패: \(error.localizedDescription)")
}
}
}
10 changes: 10 additions & 0 deletions Keychy/Keychy/Presentation/Home/Views/MyPage/MyPageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,16 @@ extension MyPageView {
.foregroundStyle(.black100)
}
.buttonStyle(.plain)
.padding(.bottom, 30)

Button {
router.push(.purchaseHistory)
} label: {
Text("구매내역")
.typography(.suit16M)
.foregroundStyle(.black100)
}
.buttonStyle(.plain)

Divider()
.padding(.top, 30)
Expand Down
Loading