checkpoint: goals feature, wallet balance, and goals/wallet detail UI

- Add goals feature (models, migrations, API, web pages)
- Add reserved/centralized wallet balance service
- Add wallet detail page and overview components
- Add new UI components (progress, multi-select, FAB)
- Remove stray empty -H/-d files from working tree
This commit is contained in:
Dwindi Ramadhana
2026-06-17 20:40:00 +07:00
parent 35e93b826a
commit 6a6e74562c
401 changed files with 9517 additions and 397 deletions

452
apps/api/prisma/schema.prisma Normal file → Executable file
View File

@@ -9,39 +9,37 @@ datasource db {
}
model User {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status String @default("active")
email String @unique
emailVerified Boolean @default(false)
passwordHash String?
name String?
avatarUrl String?
phone String? @unique
defaultCurrency String?
timeZone String?
// OTP/MFA fields
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status String @default("active")
email String @unique
name String?
avatarUrl String?
defaultCurrency String?
timeZone String?
emailVerified Boolean @default(false)
otpEmailEnabled Boolean @default(false)
otpWhatsappEnabled Boolean @default(false)
otpTotpEnabled Boolean @default(false)
otpTotpSecret String?
// Admin fields
role String @default("user") // "user" | "admin"
suspendedAt DateTime?
suspendedReason String?
lastLoginAt DateTime?
// Relations
authAccounts AuthAccount[]
categories Category[]
Recurrence Recurrence[]
sessions Session[]
transactions Transaction[]
wallets Wallet[]
subscriptions Subscription[]
payments Payment[]
apiKeys ApiKey[]
webhooks Webhook[]
passwordHash String?
otpWhatsappEnabled Boolean @default(false)
phone String? @unique
lastLoginAt DateTime?
role String @default("user")
suspendedAt DateTime?
suspendedReason String?
apiKeys ApiKey[]
authAccounts AuthAccount[]
categories Category[]
goals Goal[]
payments Payment[]
Recurrence Recurrence[]
sessions Session[]
subscriptions Subscription?
transactions Transaction[]
wallets Wallet[]
webhooks Webhook[]
}
model AuthAccount {
@@ -71,19 +69,21 @@ model Session {
}
model Wallet {
id String @id @default(uuid())
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
kind String
name String
currency String?
unit String?
initialAmount Decimal? @db.Decimal(18, 2)
pricePerUnit Decimal? @db.Decimal(18, 2)
deletedAt DateTime?
transactions Transaction[]
user User @relation(fields: [userId], references: [id])
id String @id @default(uuid())
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
kind String
name String
currency String?
unit String?
deletedAt DateTime?
initialAmount Decimal? @db.Decimal(18, 2)
pricePerUnit Decimal? @db.Decimal(18, 2)
reservedBalance Decimal @default(0) @db.Decimal(18, 2)
goalAllocations GoalAllocation[]
transactions Transaction[]
user User @relation(fields: [userId], references: [id])
@@index([userId])
}
@@ -140,36 +140,32 @@ model CurrencyRate {
@@index([base, quote])
}
// ============================================
// SUBSCRIPTION & PAYMENT MODELS
// ============================================
model Plan {
id String @id @default(uuid())
name String
slug String @unique
description String?
price Decimal @db.Decimal(10, 2)
currency String @default("IDR")
durationType String // "monthly" | "yearly" | "lifetime" | "custom"
durationDays Int?
trialDays Int @default(7)
features Json
badge String?
badgeColor String?
highlightColor String?
sortOrder Int @default(0)
isActive Boolean @default(true)
isVisible Boolean @default(true)
isFeatured Boolean @default(false)
maxWallets Int?
maxGoals Int?
maxTeamMembers Int?
apiEnabled Boolean @default(false)
apiRateLimit Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscriptions Subscription[]
id String @id @default(uuid())
name String
slug String @unique
description String?
price Decimal @db.Decimal(10, 2)
currency String @default("IDR")
durationType String
durationDays Int?
trialDays Int @default(7)
features Json
badge String?
badgeColor String?
highlightColor String?
sortOrder Int @default(0)
isActive Boolean @default(true)
isVisible Boolean @default(true)
isFeatured Boolean @default(false)
maxWallets Int?
maxGoals Int?
maxTeamMembers Int?
apiEnabled Boolean @default(false)
apiRateLimit Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscriptions Subscription[]
@@index([slug])
@@index([isActive])
@@ -178,21 +174,21 @@ model Plan {
}
model Subscription {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
planId String
plan Plan @relation(fields: [planId], references: [id])
status String // "active" | "expired" | "cancelled" | "grace_period"
startDate DateTime
endDate DateTime
isTrialUsed Boolean @default(false)
trialEndDate DateTime?
cancelledAt DateTime?
id String @id @default(uuid())
userId String @unique
planId String
status String
startDate DateTime
endDate DateTime
isTrialUsed Boolean @default(false)
trialEndDate DateTime?
cancelledAt DateTime?
cancellationReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
payments Payment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
payments Payment[]
plan Plan @relation(fields: [planId], references: [id])
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@index([status])
@@ -200,35 +196,35 @@ model Subscription {
}
model Payment {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
subscriptionId String?
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
invoiceNumber String @unique
amount Decimal @db.Decimal(10, 2)
currency String @default("IDR")
method String
tripayReference String? @unique
tripayFee Decimal? @db.Decimal(10, 2)
totalAmount Decimal @db.Decimal(10, 2)
paymentChannel String?
paymentUrl String?
qrUrl String?
status String
proofImageUrl String?
transferDate DateTime?
verifiedBy String?
verifiedAt DateTime?
rejectionReason String?
couponId String?
coupon Coupon? @relation(fields: [couponId], references: [id])
discountAmount Decimal? @db.Decimal(10, 2)
notes String?
expiresAt DateTime?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
userId String
subscriptionId String?
invoiceNumber String @unique
amount Decimal @db.Decimal(10, 2)
currency String @default("IDR")
method String
tripayReference String? @unique
tripayFee Decimal? @db.Decimal(10, 2)
totalAmount Decimal @db.Decimal(10, 2)
paymentChannel String?
paymentUrl String?
qrUrl String?
status String
proofImageUrl String?
transferDate DateTime?
verifiedBy String?
verifiedAt DateTime?
rejectionReason String?
couponId String?
discountAmount Decimal? @db.Decimal(10, 2)
notes String?
expiresAt DateTime?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coupon Coupon? @relation(fields: [couponId], references: [id])
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@index([status])
@@ -237,126 +233,180 @@ model Payment {
}
model PaymentMethod {
id String @id @default(uuid())
type String
provider String
accountName String
accountNumber String
displayName String
logoUrl String?
instructions String?
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
type String
provider String
accountName String
accountNumber String
displayName String
logoUrl String?
instructions String?
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([sortOrder])
}
model Coupon {
id String @id @default(uuid())
code String @unique
name String
description String?
discountType String
discountValue Decimal @db.Decimal(10, 2)
maxDiscount Decimal? @db.Decimal(10, 2)
validFrom DateTime
validUntil DateTime
maxUses Int?
usedCount Int @default(0)
minPurchase Decimal? @db.Decimal(10, 2)
applicablePlans String[]
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
payments Payment[]
id String @id @default(uuid())
code String @unique
name String
description String?
discountType String
discountValue Decimal @db.Decimal(10, 2)
maxDiscount Decimal? @db.Decimal(10, 2)
validFrom DateTime
validUntil DateTime
maxUses Int?
usedCount Int @default(0)
minPurchase Decimal? @db.Decimal(10, 2)
applicablePlans String[]
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
payments Payment[]
@@index([code])
@@index([isActive])
}
model AppConfig {
id String @id @default(uuid())
key String @unique
value String
category String
label String
description String?
type String
isSecret Boolean @default(false)
updatedAt DateTime @updatedAt
updatedBy String?
id String @id @default(uuid())
key String @unique
value String
category String
label String
description String?
type String
isSecret Boolean @default(false)
updatedAt DateTime @updatedAt
updatedBy String?
@@index([category])
}
// ============================================
// API & WEBHOOK MODELS
// ============================================
model ApiKey {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
name String
keyHash String @unique
prefix String
scopes String[]
lastUsedAt DateTime?
expiresAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
usage ApiKeyUsage[]
id String @id @default(uuid())
userId String
name String
keyHash String @unique
prefix String
scopes String[]
lastUsedAt DateTime?
expiresAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
usage ApiKeyUsage[]
@@index([userId])
@@index([keyHash])
}
model ApiKeyUsage {
id String @id @default(uuid())
apiKeyId String
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
endpoint String
method String
statusCode Int
responseTime Int
timestamp DateTime @default(now())
id String @id @default(uuid())
apiKeyId String
endpoint String
method String
statusCode Int
responseTime Int
timestamp DateTime @default(now())
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
@@index([apiKeyId, timestamp])
}
model Webhook {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
url String
events String[]
secret String
isActive Boolean @default(true)
lastTriggeredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deliveries WebhookDelivery[]
id String @id @default(uuid())
userId String
url String
events String[]
secret String
isActive Boolean @default(true)
lastTriggeredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
deliveries WebhookDelivery[]
@@index([userId])
}
model WebhookDelivery {
id String @id @default(uuid())
webhookId String
webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
event String
payload Json
status String
statusCode Int?
response String?
attempts Int @default(0)
nextRetryAt DateTime?
deliveredAt DateTime?
createdAt DateTime @default(now())
id String @id @default(uuid())
webhookId String
event String
payload Json
status String
statusCode Int?
response String?
attempts Int @default(0)
nextRetryAt DateTime?
deliveredAt DateTime?
createdAt DateTime @default(now())
webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
@@index([webhookId])
@@index([status])
}
model Goal {
id String @id @default(uuid())
userId String
teamId String?
name String
description String?
targetAmount Decimal @db.Decimal(18, 2)
currentAmount Decimal @default(0) @db.Decimal(18, 2)
currency String @default("IDR")
targetDate DateTime?
imageUrl String?
category String?
status String @default("active")
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
allocations GoalAllocation[]
milestones GoalMilestone[]
@@index([userId])
@@index([status])
@@index([teamId])
}
model GoalAllocation {
id String @id @default(uuid())
goalId String
walletId String
amount Decimal @db.Decimal(18, 2)
currency String
exchangeRate Decimal? @db.Decimal(18, 6)
amountInGoalCurrency Decimal @db.Decimal(18, 2)
notes String?
createdAt DateTime @default(now())
createdBy String
goal Goal @relation(fields: [goalId], references: [id], onDelete: Cascade)
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
@@index([goalId])
@@index([walletId])
@@index([createdAt])
}
model GoalMilestone {
id String @id @default(uuid())
goalId String
percentage Int
targetAmount Decimal @db.Decimal(18, 2)
achievedAt DateTime?
notifiedAt DateTime?
goal Goal @relation(fields: [goalId], references: [id], onDelete: Cascade)
@@unique([goalId, percentage])
@@index([goalId])
@@index([achievedAt])
}