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

View File

@@ -0,0 +1,87 @@
-- CreateTable
CREATE TABLE "public"."Goal" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"teamId" TEXT,
"name" TEXT NOT NULL,
"description" TEXT,
"targetAmount" DECIMAL(18,2) NOT NULL,
"currentAmount" DECIMAL(18,2) NOT NULL DEFAULT 0,
"currency" TEXT NOT NULL DEFAULT 'IDR',
"targetDate" TIMESTAMP(3),
"imageUrl" TEXT,
"category" TEXT,
"status" TEXT NOT NULL DEFAULT 'active',
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Goal_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."GoalAllocation" (
"id" TEXT NOT NULL,
"goalId" TEXT NOT NULL,
"walletId" TEXT NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"currency" TEXT NOT NULL,
"exchangeRate" DECIMAL(18,6),
"amountInGoalCurrency" DECIMAL(18,2) NOT NULL,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdBy" TEXT NOT NULL,
CONSTRAINT "GoalAllocation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."GoalMilestone" (
"id" TEXT NOT NULL,
"goalId" TEXT NOT NULL,
"percentage" INTEGER NOT NULL,
"targetAmount" DECIMAL(18,2) NOT NULL,
"achievedAt" TIMESTAMP(3),
"notifiedAt" TIMESTAMP(3),
CONSTRAINT "GoalMilestone_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Goal_userId_idx" ON "public"."Goal"("userId");
-- CreateIndex
CREATE INDEX "Goal_status_idx" ON "public"."Goal"("status");
-- CreateIndex
CREATE INDEX "Goal_teamId_idx" ON "public"."Goal"("teamId");
-- CreateIndex
CREATE INDEX "GoalAllocation_goalId_idx" ON "public"."GoalAllocation"("goalId");
-- CreateIndex
CREATE INDEX "GoalAllocation_walletId_idx" ON "public"."GoalAllocation"("walletId");
-- CreateIndex
CREATE INDEX "GoalAllocation_createdAt_idx" ON "public"."GoalAllocation"("createdAt");
-- CreateIndex
CREATE INDEX "GoalMilestone_goalId_idx" ON "public"."GoalMilestone"("goalId");
-- CreateIndex
CREATE INDEX "GoalMilestone_achievedAt_idx" ON "public"."GoalMilestone"("achievedAt");
-- CreateIndex
CREATE UNIQUE INDEX "GoalMilestone_goalId_percentage_key" ON "public"."GoalMilestone"("goalId", "percentage");
-- AddForeignKey
ALTER TABLE "public"."Goal" ADD CONSTRAINT "Goal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."GoalAllocation" ADD CONSTRAINT "GoalAllocation_goalId_fkey" FOREIGN KEY ("goalId") REFERENCES "public"."Goal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."GoalAllocation" ADD CONSTRAINT "GoalAllocation_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "public"."Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."GoalMilestone" ADD CONSTRAINT "GoalMilestone_goalId_fkey" FOREIGN KEY ("goalId") REFERENCES "public"."Goal"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Wallet" ADD COLUMN "reservedBalance" DECIMAL(18,2) NOT NULL DEFAULT 0;

0
apps/api/prisma/migrations/migration_lock.toml Normal file → Executable file
View File

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])
}