16 Commits

Author SHA1 Message Date
suphicakir b18f918baf feat: Faz 7b - kart ve KMH dönemleri, taksitler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:19:17 +03:00
suphicakir c711eb8d28 chore(release): 0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:19:17 +03:00
suphicakir 6ae98a1cb4 fix(seed): çakışan transfer seedRef'leri; örnek taksitli alışverişler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:19:17 +03:00
suphicakir 0ad52757ad feat(ui): kart özeti, KMH paneli, taksit seçimi ve kalan taksit iptali
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:19:17 +03:00
suphicakir cc624af69f feat(cards): kart/KMH dönem hesabı, taksitli harcama, kart ve KMH özeti servisleri, 006 migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:19:17 +03:00
suphicakir 57067b0fd0 feat: Faz 7a - planlı ve düzenli işlemler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:55:05 +03:00
suphicakir 8c4771908c chore: CHANGELOG (Faz 7a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:55:05 +03:00
suphicakir 5bd2c372bf feat(seed): örnek düzenli işlemler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:55:05 +03:00
suphicakir cfc2b2c974 feat(ui): planlı işlemler sayfası, kural formu ve vadesi geçmiş uyarısı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:55:05 +03:00
suphicakir 04a17d2775 feat(recurring): düzenli işlem kuralları, planlı kayıt üretimi, onay/atla/ertele ve seri düzenleme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:55:05 +03:00
suphicakir eaafe5dd9b docs: Faz 7 kararlarını kaydet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:55:05 +03:00
suphicakir 2d3a9e3a68 feat: Faz 6c - ek dosyalar, carousel ve mutabakat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:55:11 +03:00
suphicakir 43053898b4 chore(release): 0.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:55:04 +03:00
suphicakir 8a9f5ddc7d feat(ui): ek ekleme alanı, carousel görüntüleyici, ekstre sayfası ve mutabakat penceresi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:55:04 +03:00
suphicakir 2e2e33233f feat(transactions): ek bağlama, hesap ekstresi ve mutabakat servisi, 004 migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:55:04 +03:00
suphicakir 2e1f4d7929 feat(attachments): depolama soyutlaması, içerikten tür tespiti, sharp/HEIC dönüşümü ve yetkili dosya servisi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:55:04 +03:00
80 changed files with 8542 additions and 392 deletions
+65 -1
View File
@@ -6,7 +6,71 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
## [Yayınlanmamış] ## [Yayınlanmamış]
Faz 6 (alt fazlar halinde; `v0.6.0` Faz 6c sonunda). ## [0.7.0] - 2026-11-05
Faz 7: planlı/düzenli işlemler, kredi kartı ve KMH dönemleri, taksitler (7a ve 7b alt fazları).
### Eklendi — Faz 7b: kart ve KMH dönemleri, taksitler
- Dönem hesabı (`shared/card-periods.ts`): kesim günü dahil "Hesap Kesim Dönemi", sonrası "Güncel Dönem"; kesim günü ayda yoksa ayın son günü; kesim günü bitmeden dönem kapanmış sayılmaz. Son ödeme: kesimden N gün sonra veya ayın sabit günü.
- Kart özeti (`/hesaplar/:id`, kredi kartı): toplam borç, kullanılabilir limit (gelecek taksitler dahil düşülür) ve kullanım çubuğu; hesap kesim dönemi borcu, asgari ödeme, ödenen, kalan, son ödeme tarihi ve kalan gün; güncel dönem harcaması; dönem dönem yaklaşan taksitler; planlı harcamalar; son 6 ekstre.
- Asgari ödeme: limit 50.000 TL altı %20, üstü %40; kart bazında oran girilebilir (hesap formu).
- "Borcu öde": ödeme hesabından karta transfer formu kalan dönem borcuyla dolu açılır; ödemeler dönem borcundan düşer (kısmi ödeme desteklenir).
- Taksitli harcama: kredi kartı giderinde taksit sayısı (2–36). Her taksit ayrı işlem ("Hepsiburada 4/9"); toplam kuruş kaybı olmadan bölünür; vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler planlıdır ve bakiyeyi etkilemez. Ayrıntıdan "Kalan taksitleri iptal et".
- KMH özeti (KMH'li banka hesabı): limit, kullanılan, kullanılabilir; geçen ve bu dönem için gün sonu eksi bakiyelerden tahmini faiz; "Faiz kaydı ekle" kesim tarihli, "Banka › Faiz" kategorili gider formunu doldurur (vergiler hariç not).
- `statements` koleksiyonu: kart/KMH dönem kayıtları (borç, asgari, ödenen, harcama, tahmini faiz); kart özeti okunurken işlemlerden yeniden hesaplanır.
- İşlem formuna ön doldurma desteği (kart ödemesi, faiz kaydı).
- Seed: iki taksitli kart alışverişi.
- `006` migration'ı: `statements` index'leri, taksit planı index'i.
- Testler: dönem/son ödeme/asgari/taksit planı/KMH faizi (birim); kart özeti tutarlılığı (bağımsız bakiye hesabıyla), ödeme etkisi, taksit üretimi ve otomatik gerçekleşme, taksit iptali, taksit kuralları, KMH faiz tahmini ve yetki (entegrasyon).
### Düzeltildi — Faz 7b
- Seed: aynı aydaki satıcısız/kategorisiz transferler aynı `seedRef`'i aldığı için kart borcu ödemesi ve aylık birikim transferleri hiç oluşmuyordu; artık oluşuyor (mevcut kayıtlar kopyalanmaz). Kart ödemesi son ödeme gününden önceye alındı.
### Eklendi — Faz 7a: planlı ve düzenli işlemler
- Düzenli işlem kuralları (`recurringRules`): gelir, gider, transfer; sabit veya tahmini tutar; günlük/haftalık/aylık/yıllık, "her N birimde bir", ayın N. günü (ayda yoksa son gün) veya ayın son günü; bitiş yok, tekrar sayısı (ör. 12) veya bitiş tarihi. Formda Türkçe tekrar açıklaması ve ilk tarihlerin önizlemesi.
- Her tekrar için `planned` durumunda planlı kayıt; bitiş tarihsiz kurallarda 12 ay ileriye kadar. Ufuk sayfa okunurken ilerletilir (zamanlanmış görev gerekmez); üretim idempotent (`recurringRuleId + occurrenceIndex` benzersiz). Planlı kayıtlar bakiyeyi etkilemez.
- `/planli`: vadesi geçmiş, yaklaşan (60 gün) ve kurallar sekmeleri; "Onayla" (tutar ve tarih düzeltilebilir), "Ertele", "Atla"; seri pozisyonu ("3/12") ve "Tahmini" rozetleri.
- Seri düzenleme: "Yalnızca bu" (kayıt seriden ayrılır), "Bu ve sonrakiler" (seri bölünür), "Tümü" (onaylanmamış planlı kayıtlar yeniden üretilir; gerçekleşen, atlanan ve ayrılmış kayıtlar korunur). Silmede "bu ve sonrakiler" / "tüm seri"; gerçekleşmiş kayıtlar silinmez.
- Dashboard'da vadesi geçmiş planlı işlem uyarısı.
- Seed: 11 örnek düzenli işlem (maaş, kira, aidat, tahmini faturalar, abonelikler, emekli maaşı; vadesi geçmiş örnek olarak 12 taksitli spor salonu).
- `005` migration'ı: `recurringRules` index'leri, işlemlerde tekrar alanları. Denetim kaydı: `recurring.delete`.
- Testler: tekrar tarihi hesabı (ay sonu, artık yıl, aralık, sayı/bitiş/ufuk) ve form şeması (birim); üretim, onay/atla/ertele, seri düzenleme ve silme, bakiye etkisizliği, yetki ve gizli hesap izolasyonu (entegrasyon).
### Değişti — Faz 7a
- İşlem listesi varsayılan olarak planlı kayıtları göstermez (durum filtresiyle görülebilir); özet toplamları seçili duruma göre hesaplanır.
## [0.6.0] - 2026-11-05
Faz 6: hesaplar, kategoriler, işlemler, ek dosyalar ve mutabakat (6a, 6b, 6c alt fazları).
### Eklendi — Faz 6c: ek dosyalar, carousel, ekstre ve mutabakat
- Depolama soyutlaması `lib/storage/` (`put`, `get`, `delete`); yerel disk sürücüsü (`UPLOAD_DIR`), atomik yazma, rastgele anahtarlar, yol geçişine karşı anahtar doğrulaması.
- Ek dosya yükleme `POST /api/files`: tür dosya içeriğinden (imza) tespit edilir — JPEG, PNG, WebP, HEIC, PDF; uzantıya güvenilmez. Dosya başına `MAX_UPLOAD_MB` (10 MB), kayıt başına 10 dosya. Origin kontrolü.
- Görseller sharp ile EXIF yönüne göre döndürülür, en fazla 2000 px'e küçültülür, WebP'ye çevrilir; EXIF/GPS dahil tüm meta veri silinir; 320 px küçük resim üretilir. iPhone HEIC dosyaları libheif (WASM, `heic-decode`) ile çözülür. PDF'ler yapı kontrolünden sonra olduğu gibi saklanır.
- Yetkili servis `GET /api/files/:id` (`?v=thumb`, `&indir=1`): oturum + hane üyeliği; bekleyen ek yalnızca yükleyene; silinmiş işlemin veya başkasının gizli hesabındaki işlemin eki görünmez (404). Dosyalara özel sıkı CSP, `nosniff`, `private` önbellek.
- `attachments` koleksiyonu (`ownerType`, `ownerId`, tür, boyut, yükleyen, sıra). Ekler işlemle aynı MongoDB transaction'ında bağlanır; hata olursa işlem de oluşmaz. Gider: yalnızca görsel; gelir ve transfer: görsel veya PDF.
- İşlem formunda küçük "Ek ekle" alanı (çoklu seçim, yükleme göstergesi, küçük önizleme, kaldırma, sayaç); yüklemeler bitmeden kaydedilemez. Kopyada ekler taşınmaz.
- Görüntüleyici: shadcn Carousel (Embla) — oklar, kaydırma, klavye okları, tam ekran, "2 / 5" göstergesi, indirme; PDF'ler ikon + "Aç" düğmesiyle. Ayrıntıda en fazla 4 küçük resim + "+N".
- Temizlik `npm run cleanup`: 24 saatten eski bağlanmamış ekler, 30 günden önce kayıttan çıkarılmış ekler ve 30 günden önce silinmiş işlemlerin ekleri (dosyalarıyla) silinir.
- Hesap ekstresi `/hesaplar/:id?ay=YYYY-MM`: ay başı/ay sonu bakiyesi, giriş/çıkış, yürüyen bakiye, mutabakat işaretleri; işlem ayrıntısı ekstreden açılır. Başkasının gizli hesabına transfer ekstrede görünür ama karşı hesap adı gizlenir.
- Mutabakat: ekstre tarihi ve bakiyesi girilir, uygulama bakiyesiyle karşılaştırılır (kredi kartında borç pozitif girilir). Fark varsa onayla "Mutabakat düzeltmesi" işlemi oluşturulur; o tarihe kadarki işlemler hesap bazında işaretlenir; `reconciliations` kaydı ve denetim kaydı (`account.reconcile`) tek transaction'da.
- `004` migration'ı: `attachments` ve `reconciliations` index'leri; işlemlerde `reconciledAccountIds` ve `isAdjustment`.
- Testler: dosya türü tespiti, depolama, ekstre yardımcıları (birim); yükleme/dönüştürme (EXIF silme, yön, boyut, HEIC), bağlama kuralları, erişim izolasyonu, temizlik, ekstre tutarlılığı, mutabakat (entegrasyon).
### Değişti — Faz 6c
- Hesap adı artık hesap ekstresini açar (gizli hesaplarda bağlantı yok).
- Bakiyeyi etkileyen alanı (tutar, tarih, tür, hesap) değiştirilen işlemin mutabakat işareti kalkar. Mutabakat düzeltmesi düzenlenemez, yalnızca silinebilir.
- İşlemlerdeki kullanılmayan `reconciledAt` alanı, hesap bazında `reconciledAccountIds` ile değiştirildi.
### Düzeltildi
- `seed:reset` işlemleri ve satıcıları silmiyordu; artık işlemler, satıcılar, ekler (diskteki dosyalarıyla) ve mutabakatlar da temizlenir.
### Eklendi — Faz 6b: işlemler ### Eklendi — Faz 6b: işlemler
+13 -10
View File
@@ -52,16 +52,19 @@ Giriş kullanıcı adı **veya** e-posta ile yapılır. Kayıt kapalıdır; kull
## Scriptler ## Scriptler
| Komut | Açıklama | | Komut | Açıklama |
| ----------------------------- | -------------------------------- | | ----------------------------- | --------------------------------- |
| `npm run dev` | Geliştirme sunucusu | | `npm run dev` | Geliştirme sunucusu |
| `npm run build` / `start` | Üretim build'i / sunucusu | | `npm run build` / `start` | Üretim build'i / sunucusu |
| `npm run lint` | ESLint | | `npm run lint` | ESLint |
| `npm run typecheck` | `tsc --noEmit` | | `npm run typecheck` | `tsc --noEmit` |
| `npm run test` | Vitest birim testleri | | `npm run test` | Vitest birim testleri |
| `npm run format` | Prettier | | `npm run format` | Prettier |
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur | | `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) | | `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
| `npm run test:integration` | Entegrasyon testleri (MongoDB) |
| `npm run migrate` | Bekleyen migration'lar |
| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil |
## Haneler ve yetki ## Haneler ve yetki
+1
View File
@@ -318,6 +318,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
- **URL'ler:** ekran yolları Türkçe ASCII (`/islemler`, `/butce`); kod, koleksiyon adları ve `/api/v1` İngilizce. - **URL'ler:** ekran yolları Türkçe ASCII (`/islemler`, `/butce`); kod, koleksiyon adları ve `/api/v1` İngilizce.
- **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı). - **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı).
- **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır. - **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır.
- **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir.
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir. - **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
--- ---
+24 -1
View File
@@ -42,10 +42,24 @@ const securityHeaders = [
[{ key: "Strict-Transport-Security", value: "max-age=31536000" }]), [{ key: "Strict-Transport-Security", value: "max-age=31536000" }]),
]; ];
/**
* /api/files: kullanıcı dosyaları (WebP görsel, PDF). Sayfa CSP'si yerine daha sıkı bir politika:
* betik yok; PDF'in tarayıcı görüntüleyicisinde açılabilmesi için object-src 'self'.
*/
const fileCsp = [
"default-src 'none'",
"img-src 'self'",
"style-src 'unsafe-inline'",
"object-src 'self'",
"frame-ancestors 'self'",
].join("; ");
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Docker imajı için yalnızca gerekli dosyaları .next/standalone altına kopyala. // Docker imajı için yalnızca gerekli dosyaları .next/standalone altına kopyala.
output: "standalone", output: "standalone",
poweredByHeader: false, poweredByHeader: false,
// HEIC çözücü (libheif WASM) bundle'a girmesin; Node require ile yüklensin.
serverExternalPackages: ["heic-decode", "libheif-js"],
experimental: { experimental: {
agentFeedback: true, agentFeedback: true,
}, },
@@ -60,7 +74,16 @@ const nextConfig: NextConfig = {
}, },
}, },
async headers() { async headers() {
return [{ source: "/:path*", headers: securityHeaders }]; return [
{ source: "/:path((?!api/files/).*)", headers: securityHeaders },
{
source: "/api/files/:path*",
headers: [
...securityHeaders.filter((h) => h.key !== "Content-Security-Policy"),
{ key: "Content-Security-Policy", value: fileCsp },
],
},
];
}, },
}; };
+53 -2
View File
@@ -1,18 +1,20 @@
{ {
"name": "butcem", "name": "butcem",
"version": "0.1.0", "version": "0.7.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "butcem", "name": "butcem",
"version": "0.1.0", "version": "0.7.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^7.0.2", "@hookform/resolvers": "^7.0.2",
"@tanstack/react-table": "^9.3.1", "@tanstack/react-table": "^9.3.1",
"better-auth": "^1.7.17", "better-auth": "^1.7.17",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cn": "^0.5.2", "cn": "^0.5.2",
"embla-carousel-react": "^8.6.0",
"heic-decode": "^2.1.0",
"lucide-react": "^1.68.0", "lucide-react": "^1.68.0",
"mongodb": "~7.7", "mongodb": "~7.7",
"mongoose": "^9.11.4", "mongoose": "^9.11.4",
@@ -6861,6 +6863,34 @@
"integrity": "sha512-LnPnRZ0esKB9NjsSIaC+mffW+5dAtBui6kbK5f9Qykblj4Q33cn6yIhuCcTVjwpvL3YJPqqQ4IlsNyriGNpyFQ==", "integrity": "sha512-LnPnRZ0esKB9NjsSIaC+mffW+5dAtBui6kbK5f9Qykblj4Q33cn6yIhuCcTVjwpvL3YJPqqQ4IlsNyriGNpyFQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/embla-carousel": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT"
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
"license": "MIT",
"dependencies": {
"embla-carousel": "8.6.0",
"embla-carousel-reactive-utils": "8.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/embla-carousel-reactive-utils": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
"license": "MIT",
"peerDependencies": {
"embla-carousel": "8.6.0"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -8205,6 +8235,18 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/heic-decode": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
"license": "ISC",
"dependencies": {
"libheif-js": "^1.19.8"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/hermes-estree": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -9076,6 +9118,15 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/libheif-js": {
"version": "1.23.5",
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.5.tgz",
"integrity": "sha512-umXZPthnWZtF3iG/5mQG+AZ2V6i0UeYpo3z2CkggTEgL8dKmIIwJT6TDuAMuabXa1yTW8uJ1ZAgR+CxNTklYYw==",
"license": "LGPL-3.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "butcem", "name": "butcem",
"version": "0.5.0", "version": "0.7.0",
"private": true, "private": true,
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
"engines": { "engines": {
@@ -22,7 +22,8 @@
"seed:reset": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts --reset", "seed:reset": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts --reset",
"icons": "tsx scripts/generate-icons.ts", "icons": "tsx scripts/generate-icons.ts",
"test:integration": "vitest run --config vitest.integration.config.mts", "test:integration": "vitest run --config vitest.integration.config.mts",
"migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts" "migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts",
"cleanup": "tsx --conditions=react-server --env-file=.env.local scripts/cleanup.ts"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^7.0.2", "@hookform/resolvers": "^7.0.2",
@@ -30,6 +31,8 @@
"better-auth": "^1.7.17", "better-auth": "^1.7.17",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cn": "^0.5.2", "cn": "^0.5.2",
"embla-carousel-react": "^8.6.0",
"heic-decode": "^2.1.0",
"lucide-react": "^1.68.0", "lucide-react": "^1.68.0",
"mongodb": "~7.7", "mongodb": "~7.7",
"mongoose": "^9.11.4", "mongoose": "^9.11.4",
+18
View File
@@ -0,0 +1,18 @@
/**
* Ek dosya temizliği: npm run cleanup (canlıda günlük zamanlanmış görev; bkz. Faz 15).
* 24 saatten eski bağlanmamış ekler ve 30 günden önce silinmiş kayıtların ekleri fiziksel olarak silinir.
*/
import mongoose from "mongoose";
import { purgeAttachments } from "@/features/attachments/service";
import { getMongoClient } from "@/lib/db/mongo";
purgeAttachments()
.then(({ removed }) => console.log(`✓ Temizlenen ek dosya: ${removed}`))
.catch((err: unknown) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
})
.finally(async () => {
await mongoose.disconnect();
await getMongoClient().close();
});
@@ -0,0 +1,20 @@
import { AttachmentModel } from "@/models/attachment";
import { ReconciliationModel } from "@/models/reconciliation";
import type { Migration } from "./types";
export const migration: Migration = {
name: "004-attachments-reconciliation",
description: "attachments ve reconciliations index'leri; işlemlerde mutabakat alanları",
async up(db) {
await AttachmentModel.createIndexes();
await ReconciliationModel.createIndexes();
const tx = db.collection("transactions");
// reconciledAt (hiç kullanılmadı) → hesap bazında reconciledAccountIds.
await tx.updateMany({ reconciledAt: { $exists: true } }, { $unset: { reconciledAt: "" } });
await tx.updateMany(
{ reconciledAccountIds: { $exists: false } },
{ $set: { reconciledAccountIds: [] } },
);
await tx.updateMany({ isAdjustment: { $exists: false } }, { $set: { isAdjustment: false } });
},
};
+17
View File
@@ -0,0 +1,17 @@
import { RecurringRuleModel } from "@/models/recurring-rule";
import { TransactionModel } from "@/models/transaction";
import type { Migration } from "./types";
export const migration: Migration = {
name: "005-recurring",
description: "recurringRules index'leri; işlemlerde tekrar alanları ve benzersiz tekrar index'i",
async up(db) {
const tx = db.collection("transactions");
await tx.updateMany(
{ occurrenceIndex: { $exists: false } },
{ $set: { occurrenceIndex: null, detached: false, isEstimate: false } },
);
await RecurringRuleModel.createIndexes();
await TransactionModel.createIndexes();
},
};
+12
View File
@@ -0,0 +1,12 @@
import { CardStatementModel } from "@/models/card-statement";
import { TransactionModel } from "@/models/transaction";
import type { Migration } from "./types";
export const migration: Migration = {
name: "006-card-statements",
description: "statements (kart/KMH dönemleri) index'leri; taksit planı index'i",
async up() {
await CardStatementModel.createIndexes();
await TransactionModel.createIndexes();
},
};
+4 -1
View File
@@ -3,10 +3,13 @@ import { connectMongoose } from "@/lib/db/mongoose";
import { migration as m001 } from "./001-initial-indexes"; import { migration as m001 } from "./001-initial-indexes";
import { migration as m002 } from "./002-accounts-categories"; import { migration as m002 } from "./002-accounts-categories";
import { migration as m003 } from "./003-transactions"; import { migration as m003 } from "./003-transactions";
import { migration as m004 } from "./004-attachments-reconciliation";
import { migration as m005 } from "./005-recurring";
import { migration as m006 } from "./006-card-statements";
import type { Migration } from "./types"; import type { Migration } from "./types";
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
export const MIGRATIONS: Migration[] = [m001, m002, m003]; export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006];
const COLLECTION = "migrations"; const COLLECTION = "migrations";
+68
View File
@@ -0,0 +1,68 @@
import type { Types } from "mongoose";
import { createTransaction } from "@/features/transactions/service";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { TransactionModel } from "@/models/transaction";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
/**
* Örnek taksitli kart harcamaları (Faz 7b): bir kısmı gerçekleşmiş, kalanı planlı taksitler.
* İdempotent: aynı satıcıyla taksit planı varsa atlanır.
*/
const PURCHASES = [
{
payee: "Hepsiburada",
description: "Çamaşır makinesi",
amountMinor: 18_000_00,
count: 9,
daysAgo: 75,
category: "ev",
by: "admin",
},
{
payee: "Teknosa",
description: "Telefon",
amountMinor: 12_000_00,
count: 6,
daysAgo: 20,
category: "diger-gider",
by: "emre",
},
];
export async function seedInstallments(
householdId: Types.ObjectId,
userIds: Map<string, string>,
): Promise<number> {
const today = todayIstanbul();
const card = await AccountModel.findOne({ householdId, name: "Garanti Bonus" }).lean();
if (!card) return 0;
let created = 0;
for (const p of PURCHASES) {
const exists = await TransactionModel.exists({
householdId,
payeeName: p.payee,
"installment.planId": { $type: "objectId" },
});
if (exists) continue;
const category = await CategoryModel.findOne({ householdId, defaultKey: p.category }).lean();
const r = await createTransaction(userIds.get(p.by)!, String(householdId), {
type: "expense",
amount: p.amountMinor,
date: addDays(today, -p.daysAgo),
accountId: String(card._id),
toAccountId: null,
categoryId: String(category!._id),
splits: [],
payee: p.payee,
description: p.description,
tags: [],
installments: p.count,
attachmentIds: [],
});
if (!r.ok) throw new Error(`Seed taksiti oluşturulamadı (${p.payee}): ${r.error}`);
created++;
}
return created;
}
+201
View File
@@ -0,0 +1,201 @@
import type { Types } from "mongoose";
import { createRule } from "@/features/recurring/service";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { RecurringRuleModel } from "@/models/recurring-rule";
import { todayIstanbul } from "@/shared/dates";
import { addDays, addMonths } from "@/shared/recurrence";
import { recurringRuleFormSchema, type RecurringRuleFormInput } from "@/shared/schemas/recurring";
import { formatMoneyInput } from "@/shared/money";
/**
* Örnek düzenli işlemler (Faz 7a). Seed işlemleri bugüne kadarki günleri doldurur; kurallar bir
* sonraki gerçekleşme gününden başlar (çakışma yok). "Spor salonu" kasıtlı olarak 3 gün önce
* başlar: vadesi geçmiş planlı kayıt örneği. İdempotent (`seedRef`).
*/
type SeedRule = {
ref: string;
by: string;
type: "income" | "expense" | "transfer";
amountMinor: number;
account: string;
category?: string;
payee: string;
/** Ayın günü (bir sonraki gerçekleşme) veya bugüne göre gün farkı. */
day?: number;
offsetDays?: number;
isEstimate?: boolean;
count?: number;
};
const RULES: Record<string, SeedRule[]> = {
"Çakır Ailesi": [
{
ref: "maas",
by: "admin",
type: "income",
amountMinor: 52_000_00,
account: "Ziraat Maaş",
category: "maas",
payee: "Anadolu Yazılım A.Ş.",
day: 15,
},
{
ref: "kira",
by: "admin",
type: "expense",
amountMinor: 17_500_00,
account: "Ziraat Maaş",
category: "kira",
payee: "Ev sahibi",
day: 3,
},
{
ref: "aidat",
by: "admin",
type: "expense",
amountMinor: 1_250_00,
account: "Ziraat Maaş",
category: "aidat",
payee: "Site yönetimi",
day: 5,
},
{
ref: "elektrik",
by: "ayse",
type: "expense",
amountMinor: 1_200_00,
account: "Ziraat Maaş",
category: "faturalar.elektrik",
payee: "Enerjisa",
day: 10,
isEstimate: true,
},
{
ref: "su",
by: "ayse",
type: "expense",
amountMinor: 450_00,
account: "Ziraat Maaş",
category: "faturalar.su",
payee: "İSKİ",
day: 12,
isEstimate: true,
},
{
ref: "dogalgaz",
by: "ayse",
type: "expense",
amountMinor: 2_500_00,
account: "Ziraat Maaş",
category: "faturalar.dogalgaz",
payee: "İGDAŞ",
day: 14,
isEstimate: true,
},
{
ref: "internet",
by: "admin",
type: "expense",
amountMinor: 499_90,
account: "Garanti Bonus",
category: "faturalar.internet",
payee: "Türk Telekom",
day: 20,
},
{
ref: "netflix",
by: "admin",
type: "expense",
amountMinor: 229_99,
account: "Garanti Bonus",
category: "abonelikler",
payee: "Netflix",
day: 8,
},
{
ref: "spor",
by: "emre",
type: "expense",
amountMinor: 1_250_00,
account: "Garanti Bonus",
category: "eglence",
payee: "Spor salonu",
offsetDays: -3,
count: 12,
},
],
Annemler: [
{
ref: "emekli",
by: "fatma",
type: "income",
amountMinor: 24_350_00,
account: "Vakıfbank Emekli",
category: "maas",
payee: "SGK",
day: 17,
},
{
ref: "elektrik",
by: "fatma",
type: "expense",
amountMinor: 700_00,
account: "Vakıfbank Emekli",
category: "faturalar.elektrik",
payee: "Enerjisa",
day: 11,
isEstimate: true,
},
],
};
/** Bugünden sonraki ilk "ayın N. günü". */
function nextMonthDay(today: string, day: number): string {
const thisMonth = addMonths(today, 0, day);
return thisMonth > today ? thisMonth : addMonths(today, 1, day);
}
export async function seedRecurring(
householdIds: Map<string, Types.ObjectId>,
userIds: Map<string, string>,
): Promise<number> {
const today = todayIstanbul();
let created = 0;
for (const [household, rules] of Object.entries(RULES)) {
const householdId = householdIds.get(household)!;
const [accounts, categories] = await Promise.all([
AccountModel.find({ householdId }).select({ name: 1 }).lean(),
CategoryModel.find({ householdId, defaultKey: { $ne: null } })
.select({ defaultKey: 1 })
.lean(),
]);
for (const r of rules) {
const seedRef = `rule:${r.ref}`;
if (await RecurringRuleModel.exists({ householdId, seedRef })) continue;
const input: RecurringRuleFormInput = {
type: r.type,
amount: formatMoneyInput(r.amountMinor),
isEstimate: r.isEstimate ?? false,
accountId: String(accounts.find((a) => a.name === r.account)!._id),
categoryId: r.category
? String(categories.find((c) => c.defaultKey === r.category)!._id)
: null,
payee: r.payee,
startDate: r.day ? nextMonthDay(today, r.day) : addDays(today, r.offsetDays ?? 0),
frequency: "monthly",
endKind: r.count ? "count" : "never",
count: r.count ?? null,
};
const result = await createRule(
userIds.get(r.by)!,
String(householdId),
recurringRuleFormSchema.parse(input),
);
if (!result.ok) throw new Error(`Seed kuralı oluşturulamadı (${seedRef}): ${result.error}`);
await RecurringRuleModel.updateOne({ _id: result.data.id }, { $set: { seedRef } });
created++;
}
}
return created;
}
+17
View File
@@ -45,11 +45,28 @@ describe("seed", () => {
categories: 0, categories: 0,
accounts: 0, accounts: 0,
transactions: 0, transactions: 0,
recurringRules: 0,
installmentPlans: 0,
}); });
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
}); });
it("aynı aydaki transferlerin hepsi oluşur (seedRef çakışması yok)", async () => {
const tx = getDb().collection("transactions");
const months = new Set(
(await tx.find({ description: "ATM" }).toArray()).map((t) => t.seedRef.split(":")[1]),
);
expect(months.size).toBeGreaterThanOrEqual(5);
// Her ay ATM'nin yanında kart ödemesi ve birikim de var (ayın 7'si geçmişse).
expect(await tx.countDocuments({ description: "Kart borcu ödemesi" })).toBeGreaterThanOrEqual(
months.size - 1,
);
expect(await tx.countDocuments({ description: "Aylık birikim" })).toBeGreaterThanOrEqual(
months.size - 1,
);
});
it("roller ve askı durumu doğru", async () => { it("roller ve askı durumu doğru", async () => {
const users = await getDb() const users = await getDb()
.collection("user") .collection("user")
+30
View File
@@ -3,12 +3,15 @@ import { getAuth } from "@/lib/auth";
import { getDb } from "@/lib/db/mongo"; import { getDb } from "@/lib/db/mongo";
import { connectMongoose } from "@/lib/db/mongoose"; import { connectMongoose } from "@/lib/db/mongoose";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { getStorage } from "@/lib/storage";
import { installDefaultCategories } from "@/features/categories/service"; import { installDefaultCategories } from "@/features/categories/service";
import { AccountModel } from "@/models/account"; import { AccountModel } from "@/models/account";
import { HouseholdModel } from "@/models/household"; import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member"; import { HouseholdMemberModel } from "@/models/household-member";
import { storedEmail } from "@/shared/account-email"; import { storedEmail } from "@/shared/account-email";
import { runMigrations } from "../migrations"; import { runMigrations } from "../migrations";
import { seedInstallments } from "./installments";
import { seedRecurring } from "./recurring";
import { seedTransactions } from "./transactions"; import { seedTransactions } from "./transactions";
import { import {
ADMIN_USERNAME, ADMIN_USERNAME,
@@ -42,6 +45,8 @@ export type SeedSummary = {
categories: number; categories: number;
accounts: number; accounts: number;
transactions: number; transactions: number;
recurringRules: number;
installmentPlans: number;
}; };
accounts: SeedAccountRow[]; accounts: SeedAccountRow[];
}; };
@@ -53,6 +58,12 @@ const DOMAIN_COLLECTIONS = [
"auditLogs", "auditLogs",
"categories", "categories",
"accounts", "accounts",
"transactions",
"payees",
"attachments",
"reconciliations",
"recurringRules",
"statements",
]; ];
type AuthApi = ReturnType<typeof getAuth>["api"]; type AuthApi = ReturnType<typeof getAuth>["api"];
@@ -102,11 +113,21 @@ export async function runSeed({
categories: 0, categories: 0,
accounts: 0, accounts: 0,
transactions: 0, transactions: 0,
recurringRules: 0,
installmentPlans: 0,
}, },
accounts: [], accounts: [],
}; };
if (reset) { if (reset) {
// Ek dosyaların diskteki kopyaları da silinsin (yetim dosya kalmasın).
const storage = getStorage();
for await (const a of db
.collection("attachments")
.find({}, { projection: { storageKey: 1, thumbKey: 1 } })) {
await storage.delete(String(a.storageKey));
if (a.thumbKey) await storage.delete(String(a.thumbKey));
}
for (const name of [...AUTH_COLLECTIONS, ...DOMAIN_COLLECTIONS]) { for (const name of [...AUTH_COLLECTIONS, ...DOMAIN_COLLECTIONS]) {
await db.collection(name).deleteMany({}); await db.collection(name).deleteMany({});
} }
@@ -259,6 +280,15 @@ export async function runSeed({
const userIdMap = new Map([...ids.entries()]); const userIdMap = new Map([...ids.entries()]);
summary.created.transactions = await seedTransactions(householdIds, userIdMap); summary.created.transactions = await seedTransactions(householdIds, userIdMap);
if (summary.created.transactions) log(`✓ Örnek işlemler: ${summary.created.transactions}`); if (summary.created.transactions) log(`✓ Örnek işlemler: ${summary.created.transactions}`);
summary.created.recurringRules = await seedRecurring(householdIds, userIdMap);
if (summary.created.recurringRules)
log(`✓ Örnek düzenli işlemler: ${summary.created.recurringRules}`);
summary.created.installmentPlans = await seedInstallments(
householdIds.get("Çakır Ailesi")!,
userIdMap,
);
if (summary.created.installmentPlans)
log(`✓ Örnek taksitli alışverişler: ${summary.created.installmentPlans}`);
// 7) Geliştirme hesapları tablosu // 7) Geliştirme hesapları tablosu
const all = [ const all = [
+27 -19
View File
@@ -166,7 +166,8 @@ function cakirAiles(): SeedTx[] {
by: "emre", by: "emre",
}); });
// Transferler: nakit çekme, kart ödemesi, birikim. // Transferler: nakit çekme, kart ödemesi, birikim. Satıcı/kategori olmadığından seedRef
// türden üretilir; çakışmasın diye sıra numarası verilir (ATM 0 — mevcut veriyle uyumlu).
add({ add({
type: "transfer", type: "transfer",
ymd: d(2), ymd: d(2),
@@ -176,24 +177,31 @@ function cakirAiles(): SeedTx[] {
description: "ATM", description: "ATM",
by: "admin", by: "admin",
}); });
add({ add(
type: "transfer", {
ymd: d(9), type: "transfer",
amountMinor: tl(11_000, 15_000), // Kesim 29, son ödeme ~8: ödeme son ödeme gününden önce.
account: "Ziraat Maaş", ymd: d(7),
toAccount: "Garanti Bonus", amountMinor: tl(11_000, 15_000),
description: "Kart borcu ödemesi", account: "Ziraat Maaş",
by: "admin", toAccount: "Garanti Bonus",
}); description: "Kart borcu ödemesi",
add({ by: "admin",
type: "transfer", },
ymd: d(16), 1,
amountMinor: 5_000_00, );
account: "Ziraat Maaş", add(
toAccount: "Birikim (Vadeli)", {
description: "Aylık birikim", type: "transfer",
by: "ayse", ymd: d(16),
}); amountMinor: 5_000_00,
account: "Ziraat Maaş",
toAccount: "Birikim (Vadeli)",
description: "Aylık birikim",
by: "ayse",
},
2,
);
// Market: haftada 1–2; bir kısmı bölünmüş. // Market: haftada 1–2; bir kısmı bölünmüş.
const marketCount = faker.number.int({ min: 6, max: 8 }); const marketCount = faker.number.int({ min: 6, max: 8 });
+4
View File
@@ -3,6 +3,7 @@ import { Suspense } from "react";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal"; import { requireActiveHousehold } from "@/lib/dal";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -59,6 +60,9 @@ export default async function DashboardPage() {
title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)} title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)}
description={`${household.name} · ${tr.dashboard.welcomeDescription}`} description={`${household.name} · ${tr.dashboard.welcomeDescription}`}
/> />
<Suspense fallback={null}>
<OverdueBanner userId={user.id} householdId={household.id} />
</Suspense>
<Suspense fallback={<SummaryCardsSkeleton />}> <Suspense fallback={<SummaryCardsSkeleton />}>
<SummaryCards /> <SummaryCards />
</Suspense> </Suspense>
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { hasHouseholdRole } from "@/features/households/roles";
import { StatementView } from "@/features/transactions/components/statement-view";
import { getCardSummary, getOverdraftSummary } from "@/features/transactions/card";
import { currentMonth, getAccountStatement } from "@/features/transactions/statement";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { MONTH_PATTERN } from "@/shared/statement";
export const metadata: Metadata = { title: tr.statement.title };
/** /hesaplar/:id?ay=2026-11 — aylık hesap ekstresi (yürüyen bakiye) ve mutabakat. */
export default async function AccountStatementPage({
params,
searchParams,
}: PageProps<"/hesaplar/[id]">) {
const { user, household } = await requireActiveHousehold("viewer");
const [{ id }, sp] = await Promise.all([params, searchParams]);
const now = currentMonth();
const requested = typeof sp.ay === "string" && MONTH_PATTERN.test(sp.ay) ? sp.ay : now;
const month = requested > now ? now : requested;
const statement = await getAccountStatement(user.id, household.id, id, month);
if (!statement) notFound();
const [card, overdraft] = await Promise.all([
statement.account.type === "credit_card"
? getCardSummary(user.id, household.id, id)
: Promise.resolve(null),
statement.account.type === "bank"
? getOverdraftSummary(user.id, household.id, id)
: Promise.resolve(null),
]);
return (
<StatementView
key={`${id}-${month}`}
statement={statement}
card={card}
overdraft={overdraft}
canWrite={hasHouseholdRole(household.role, "member")}
/>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { hasHouseholdRole } from "@/features/households/roles";
import { PlannedView } from "@/features/recurring/components/planned-view";
import { getPlannedOverview } from "@/features/recurring/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.recurring.title };
export default async function PlannedPage() {
const { user, household } = await requireActiveHousehold("viewer");
const data = await getPlannedOverview(user.id, household.id);
if (!data) notFound();
return (
<>
<PageHeader title={tr.recurring.title} description={tr.recurring.description} />
<PlannedView data={data} canWrite={hasHouseholdRole(household.role, "member")} />
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { NextRequest } from "next/server";
import { readAttachmentForUser } from "@/features/attachments/service";
import { getCurrentUser } from "@/lib/dal";
/**
* GET /api/files/:id[?v=thumb][&indir=1] — oturum ve hane yetkisi kontrol edilerek ek dosyayı
* servis eder. Erişim yoksa (başka hane, gizli hesap, silinmiş kayıt) 404: varlık sızdırılmaz.
*/
export async function GET(request: NextRequest, ctx: RouteContext<"/api/files/[id]">) {
const user = await getCurrentUser();
if (!user) return new Response(null, { status: 401 });
const { id } = await ctx.params;
const params = request.nextUrl.searchParams;
const file = await readAttachmentForUser(
user.id,
id,
params.get("v") === "thumb" ? "thumb" : "full",
);
if (!file) return new Response(null, { status: 404 });
const disposition = params.get("indir") === "1" ? "attachment" : "inline";
const fallbackName = file.mimeType === "application/pdf" ? "belge.pdf" : "gorsel.webp";
const name = toServedName(file.name || fallbackName, file.mimeType);
return new Response(new Uint8Array(file.bytes), {
headers: {
"Content-Type": file.mimeType,
"Content-Length": String(file.bytes.length),
"Content-Disposition": `${disposition}; filename="${asciiName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`,
// Yalnızca kullanıcının tarayıcısında; paylaşılan önbelleklere girmez.
"Cache-Control": "private, max-age=3600",
"X-Content-Type-Options": "nosniff",
},
});
}
/** Görseller WebP'ye çevrildiği için indirilen adın uzantısı içerikle uyumlu olsun. */
function toServedName(name: string, mimeType: string): string {
const ext = mimeType === "application/pdf" ? ".pdf" : ".webp";
return name.replace(/\.[a-z0-9]{1,5}$/i, "") + ext;
}
function asciiName(name: string): string {
return (
name
.normalize("NFKD")
.replace(/[^\x20-\x7e]/g, "")
.replace(/["\\]/g, "") || "dosya"
);
}
+53
View File
@@ -0,0 +1,53 @@
import { uploadAttachment } from "@/features/attachments/service";
import { tr } from "@/i18n/tr";
import { fail } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { env } from "@/lib/env";
/** Multipart başlıkları için tolerans. */
const MULTIPART_OVERHEAD = 64 * 1024;
/**
* POST /api/files — tek dosya yükler (`file` alanı), aktif haneye bekleyen ek olarak kaydeder.
* Kayıt (ör. işlem) kaydedilirken ek kimlikleri gönderilir ve ek kayda bağlanır.
*/
export async function POST(request: Request) {
if (!isSameOrigin(request)) return Response.json(fail(tr.common.unauthorized), { status: 403 });
const ctx = await getHouseholdActionContext("member");
if (!ctx) return Response.json(fail(tr.common.unauthorized), { status: 401 });
const maxMb = env().MAX_UPLOAD_MB;
const length = Number(request.headers.get("content-length") ?? 0);
if (length > maxMb * 1024 * 1024 + MULTIPART_OVERHEAD) {
return Response.json(fail(tr.attachments.errors.tooLarge(maxMb)), { status: 413 });
}
let file: FormDataEntryValue | null;
try {
file = (await request.formData()).get("file");
} catch {
return Response.json(fail(tr.attachments.errors.invalid), { status: 400 });
}
if (!(file instanceof File)) {
return Response.json(fail(tr.attachments.errors.empty), { status: 400 });
}
const bytes = Buffer.from(await file.arrayBuffer());
const result = await uploadAttachment(ctx.user.id, ctx.household.id, { name: file.name, bytes });
return Response.json(result, { status: result.ok ? 201 : 400 });
}
/**
* CSRF savunması: tarayıcı isteğinde Origin varsa uygulamanın kendi host'u olmalı.
* (Oturum cookie'si SameSite=Lax; bu ek bir katman.)
*/
function isSameOrigin(request: Request): boolean {
const origin = request.headers.get("origin");
if (!origin) return true;
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
try {
return new URL(origin).host === host;
} catch {
return false;
}
}
+2 -2
View File
@@ -27,7 +27,7 @@ export type NavItem = {
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
export const CURRENT_PHASE = 6.2; export const CURRENT_PHASE = 7.2;
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
@@ -36,7 +36,7 @@ export const MAIN_NAV: NavItem[] = [
{ href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6.2 }, { href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6.2 },
{ href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 }, { href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 },
{ href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 }, { href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 },
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7 }, { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 },
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 }, { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 },
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 }, { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 },
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 }, { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
+242
View File
@@ -0,0 +1,242 @@
"use client";
import * as React from "react";
import { cn } from "cn";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { Button } from "@/components/ui/button";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
// İlk durum bir sonraki karede okunur (effect içinde senkron setState yapılmaz).
const frame = requestAnimationFrame(() => onSelect(api));
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
cancelAnimationFrame(frame);
api.off("select", onSelect);
api.off("reInit", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({
className,
containerClassName,
...props
}: React.ComponentProps<"div"> & { containerClassName?: string }) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className={cn("overflow-hidden", containerClassName)}
data-slot="carousel-content"
>
<div
className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -left-12 my-auto"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ChevronLeftIcon />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -right-12 my-auto"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ChevronRightIcon />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
};
@@ -79,6 +79,7 @@ type FormValues = {
cardDueKind: AccountFormInput["cardDueKind"]; cardDueKind: AccountFormInput["cardDueKind"];
cardDueValue: string; cardDueValue: string;
cardPaymentAccountId: string; cardPaymentAccountId: string;
cardMinimumRate: string;
}; };
const money = (minor: number | null | undefined) => (minor ? formatMoneyInput(minor) : ""); const money = (minor: number | null | undefined) => (minor ? formatMoneyInput(minor) : "");
@@ -105,6 +106,7 @@ function toValues(a?: AccountView): FormValues {
cardDueKind: "daysAfter", cardDueKind: "daysAfter",
cardDueValue: "10", cardDueValue: "10",
cardPaymentAccountId: "", cardPaymentAccountId: "",
cardMinimumRate: "",
}; };
} }
const isCard = a.type === "credit_card"; const isCard = a.type === "credit_card";
@@ -133,6 +135,10 @@ function toValues(a?: AccountView): FormValues {
cardDueKind: a.creditCard?.dueRule.kind ?? "daysAfter", cardDueKind: a.creditCard?.dueRule.kind ?? "daysAfter",
cardDueValue: String(a.creditCard?.dueRule.value ?? 10), cardDueValue: String(a.creditCard?.dueRule.value ?? 10),
cardPaymentAccountId: a.creditCard?.paymentAccountId ?? "", cardPaymentAccountId: a.creditCard?.paymentAccountId ?? "",
cardMinimumRate:
a.creditCard?.minimumPaymentRateBp != null
? formatMoneyInput(a.creditCard.minimumPaymentRateBp)
: "",
}; };
} }
@@ -499,6 +505,28 @@ export function AccountFormDialog({
</Field> </Field>
)} )}
/> />
<Controller
name="cardMinimumRate"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-card-min">{f.cardMinimumRate}</FieldLabel>
<Input
{...field}
id="acc-card-min"
inputMode="decimal"
placeholder="20"
className="w-28 tabular-nums"
aria-describedby="acc-card-min-hint"
aria-invalid={fieldState.invalid}
/>
<FieldDescription id="acc-card-min-hint">
{f.cardMinimumRateHint}
</FieldDescription>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</FieldGroup> </FieldGroup>
</FieldSet> </FieldSet>
) : null} ) : null}
@@ -110,12 +110,14 @@ export function AccountsView({
<li className={cn("flex items-center gap-3 p-3", a.archived && "opacity-60")}> <li className={cn("flex items-center gap-3 p-3", a.archived && "opacity-60")}>
<IconBadge icon={a.icon} color={a.color} /> <IconBadge icon={a.icon} color={a.color} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Link {/* Gizli hesabın ekstresi yalnızca sahibine açılır. */}
href={`/islemler?hesap=${a.id}`} {a.balanceMinor === null ? (
className="block truncate font-medium hover:underline" <span className="block truncate font-medium">{a.name}</span>
> ) : (
{a.name} <Link href={`/hesaplar/${a.id}`} className="block truncate font-medium hover:underline">
</Link> {a.name}
</Link>
)}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{a.type === "meal_card" && a.mealCardProvider ? ( {a.type === "meal_card" && a.mealCardProvider ? (
<span>{t.providers[a.mealCardProvider]}</span> <span>{t.providers[a.mealCardProvider]}</span>
@@ -35,6 +35,7 @@ const base = {
cardDueKind: "daysAfter" as const, cardDueKind: "daysAfter" as const,
cardDueValue: 10, cardDueValue: 10,
cardPaymentAccountId: null, cardPaymentAccountId: null,
cardMinimumRate: null,
}; };
const accountId = (name: string, householdId = cakir) => const accountId = (name: string, householdId = cakir) =>
+2
View File
@@ -55,6 +55,7 @@ export type AccountView = {
statementDay: number; statementDay: number;
dueRule: { kind: DueRuleKind; value: number }; dueRule: { kind: DueRuleKind; value: number };
paymentAccountId: string | null; paymentAccountId: string | null;
minimumPaymentRateBp: number | null;
} | null; } | null;
}; };
@@ -229,6 +230,7 @@ async function toDocFields(
statementDay: input.cardStatementDay, statementDay: input.cardStatementDay,
dueRule: { kind: input.cardDueKind, value: input.cardDueValue }, dueRule: { kind: input.cardDueKind, value: input.cardDueValue },
paymentAccountId, paymentAccountId,
minimumPaymentRateBp: input.cardMinimumRate,
} }
: null, : null,
}); });
@@ -0,0 +1,168 @@
"use client";
import { Loader2, Paperclip, X } from "lucide-react";
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { tr } from "@/i18n/tr";
import type { ActionResult } from "@/lib/action-result";
import { cn } from "@/lib/utils";
import {
acceptAttribute,
MAX_ATTACHMENTS_PER_RECORD,
type AttachmentKind,
} from "@/shared/attachments";
import type { AttachmentInfo } from "../service";
import { AttachmentThumb } from "./attachment-strip";
import { AttachmentViewer } from "./attachment-viewer";
const t = tr.attachments;
/** Tek dosyayı /api/files'a yükler. */
async function uploadFile(file: File): Promise<ActionResult<AttachmentInfo>> {
const body = new FormData();
body.append("file", file);
try {
const res = await fetch("/api/files", { method: "POST", body });
return (await res.json()) as ActionResult<AttachmentInfo>;
} catch {
return { ok: false, error: t.errors.upload };
}
}
/**
* Formdaki küçük "Ek ekle" alanı (Bölüm 6.3): seçilen dosyalar hemen yüklenir (bekleyen ek),
* küçük önizleme ve sayaç gösterilir; kayıt kaydedilince ekler kayda bağlanır.
*/
export function AttachmentPicker({
value,
onChange,
allowedKinds,
onBusyChange,
}: {
value: AttachmentInfo[];
onChange: (items: AttachmentInfo[]) => void;
allowedKinds: readonly AttachmentKind[];
onBusyChange?: (busy: boolean) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(0);
const [errors, setErrors] = useState<string[]>([]);
const [viewer, setViewer] = useState<number | null>(null);
const invalid = value.filter((a) => !allowedKinds.includes(a.kind));
const room = MAX_ATTACHMENTS_PER_RECORD - value.length;
async function onFiles(files: FileList | null) {
if (!files?.length) return;
const list = [...files];
const accepted = list.slice(0, Math.max(0, room));
const nextErrors: string[] = [];
if (list.length > accepted.length)
nextErrors.push(t.errors.tooMany(MAX_ATTACHMENTS_PER_RECORD));
setUploading(accepted.length);
onBusyChange?.(true);
let items = value;
for (const file of accepted) {
const result = await uploadFile(file);
if (result.ok) {
items = [...items, result.data];
onChange(items);
} else {
nextErrors.push(`${file.name}: ${result.error}`);
}
setUploading((n) => n - 1);
}
setErrors(nextErrors);
onBusyChange?.(false);
if (inputRef.current) inputRef.current.value = "";
}
function remove(id: string) {
onChange(value.filter((a) => a.id !== id));
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => inputRef.current?.click()}
disabled={room <= 0 || uploading > 0}
>
{uploading > 0 ? <Loader2 className="animate-spin" /> : <Paperclip />}
{uploading > 0 ? t.uploading : t.add}
</Button>
<span className="text-xs text-muted-foreground">
{value.length
? t.count(value.length)
: allowedKinds.includes("pdf")
? t.addHint.imagePdf
: t.addHint.image}
</span>
<input
ref={inputRef}
type="file"
multiple
accept={acceptAttribute(allowedKinds)}
className="sr-only"
tabIndex={-1}
aria-label={t.add}
onChange={(e) => onFiles(e.target.files)}
/>
</div>
{value.length || uploading ? (
<ul className="flex flex-wrap gap-1.5">
{value.map((item, i) => (
<li key={item.id} className="relative">
<AttachmentThumb
item={item}
className={cn(
"size-10",
!allowedKinds.includes(item.kind) && "border-destructive ring-1 ring-destructive",
)}
onClick={() => setViewer(i)}
/>
<button
type="button"
onClick={() => remove(item.id)}
className="absolute -top-1.5 -right-1.5 flex size-5 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-sm hover:text-foreground"
aria-label={`${t.remove}: ${item.name}`}
>
<X className="size-3" />
</button>
</li>
))}
{Array.from({ length: uploading }, (_, i) => (
<li
key={`up-${i}`}
className="flex size-10 items-center justify-center rounded-md border border-dashed"
aria-label={t.uploading}
>
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</li>
))}
</ul>
) : null}
{invalid.length ? (
<p className="text-xs text-destructive" role="alert">
{t.errors.kindNotAllowed}
</p>
) : null}
{errors.map((e) => (
<p key={e} className="text-xs text-destructive" role="alert">
{e}
</p>
))}
<AttachmentViewer
items={value}
startIndex={viewer ?? 0}
open={viewer !== null}
onOpenChange={(open) => !open && setViewer(null)}
/>
</div>
);
}
@@ -0,0 +1,75 @@
"use client";
import { FileText, Paperclip } from "lucide-react";
import { useState } from "react";
import { tr } from "@/i18n/tr";
import type { AttachmentInfo } from "../service";
import { AttachmentViewer } from "./attachment-viewer";
const t = tr.attachments;
const VISIBLE = 4;
/** Ayrıntıda küçük önizleme şeridi (en fazla 4 + "+N"); tıklayınca görüntüleyici açılır. */
export function AttachmentStrip({ items }: { items: AttachmentInfo[] }) {
const [viewer, setViewer] = useState<number | null>(null);
if (!items.length) return null;
const shown = items.slice(0, VISIBLE);
const rest = items.length - shown.length;
return (
<div className="flex items-center gap-2">
<Paperclip className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="sr-only">{t.count(items.length)}</span>
<ul className="flex gap-1.5">
{shown.map((item, i) => (
<li key={item.id} className="relative">
<AttachmentThumb item={item} onClick={() => setViewer(i)} />
{rest > 0 && i === shown.length - 1 ? (
<button
type="button"
onClick={() => setViewer(i + 1)}
className="absolute inset-0 flex items-center justify-center rounded-md bg-black/55 text-sm font-medium text-white"
aria-label={`${t.viewerTitle}: ${t.moreCount(rest)}`}
>
{t.moreCount(rest)}
</button>
) : null}
</li>
))}
</ul>
<AttachmentViewer
items={items}
startIndex={viewer ?? 0}
open={viewer !== null}
onOpenChange={(open) => !open && setViewer(null)}
/>
</div>
);
}
export function AttachmentThumb({
item,
onClick,
className = "size-12",
}: {
item: AttachmentInfo;
onClick?: () => void;
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
title={item.name}
aria-label={item.name}
className={`${className} flex items-center justify-center overflow-hidden rounded-md border bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none`}
>
{item.thumbUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- yetkili özel dosya
<img src={item.thumbUrl} alt="" className="size-full object-cover" loading="lazy" />
) : (
<FileText className="size-5 text-muted-foreground" aria-hidden />
)}
</button>
);
}
@@ -0,0 +1,170 @@
"use client";
import { Download, ExternalLink, FileText, Maximize, Minimize } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
type CarouselApi,
} from "@/components/ui/carousel";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { tr } from "@/i18n/tr";
import { formatFileSize } from "@/shared/attachments";
import type { AttachmentInfo } from "../service";
const t = tr.attachments;
/**
* Ek dosya görüntüleyici (Bölüm 6.3): carousel (oklar, kaydırma, klavye okları), tam ekran ve
* "2 / 5" göstergesi. PDF'ler ikon + "Aç" düğmesiyle gösterilir (tarayıcının PDF görüntüleyicisi).
*/
export function AttachmentViewer({
items,
startIndex,
open,
onOpenChange,
}: {
items: AttachmentInfo[];
startIndex: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [api, setApi] = useState<CarouselApi>();
const [index, setIndex] = useState(startIndex);
const [fullscreen, setFullscreen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!api) return;
const update = () => setIndex(api.selectedScrollSnap());
api.on("select", update);
api.on("reInit", update);
return () => {
api.off("select", update);
api.off("reInit", update);
};
}, [api]);
useEffect(() => {
const onChange = () => setFullscreen(document.fullscreenElement === containerRef.current);
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
function handleOpenChange(next: boolean) {
if (!next && document.fullscreenElement) void document.exitFullscreen();
if (next) setIndex(startIndex);
onOpenChange(next);
}
async function toggleFullscreen() {
if (document.fullscreenElement) await document.exitFullscreen();
else await containerRef.current?.requestFullscreen().catch(() => {});
}
/** Odak carousel dışındayken de (ör. kapat düğmesi) ok tuşları çalışsın. */
function onKeyDown(event: React.KeyboardEvent) {
if (event.defaultPrevented) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
api?.scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
api?.scrollNext();
}
}
const current = items[Math.min(index, items.length - 1)];
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="flex h-[min(90dvh,900px)] w-[calc(100vw-1rem)] max-w-5xl flex-col gap-0 p-0 sm:max-w-5xl"
onKeyDown={onKeyDown}
>
<div ref={containerRef} className="flex min-h-0 flex-1 flex-col bg-background">
<div className="flex items-center gap-2 border-b px-3 py-2 pr-12">
<DialogTitle className="min-w-0 flex-1 truncate text-sm">
{current?.name ?? t.viewerTitle}
</DialogTitle>
<DialogDescription className="sr-only">{t.viewerTitle}</DialogDescription>
<span
className="shrink-0 text-xs text-muted-foreground tabular-nums"
aria-live="polite"
>
{t.position(index + 1, items.length)}
</span>
{current ? (
<Button asChild variant="ghost" size="icon-sm" aria-label={t.download}>
<a href={`${current.url}?indir=1`} download>
<Download />
</a>
</Button>
) : null}
<Button
variant="ghost"
size="icon-sm"
onClick={toggleFullscreen}
aria-label={fullscreen ? t.exitFullscreen : t.fullscreen}
>
{fullscreen ? <Minimize /> : <Maximize />}
</Button>
</div>
<Carousel
setApi={setApi}
opts={{ startIndex, loop: false }}
className="flex min-h-0 flex-1 flex-col"
aria-label={t.viewerTitle}
>
<CarouselContent className="h-full" containerClassName="h-full min-h-0 flex-1">
{items.map((item) => (
<CarouselItem
key={item.id}
className="flex h-full items-center justify-center p-4 pl-8"
>
{item.kind === "image" ? (
// eslint-disable-next-line @next/next/no-img-element -- yetkili özel dosya; optimizasyon hattına girmez
<img
src={item.url}
alt={item.name}
width={item.width ?? undefined}
height={item.height ?? undefined}
className="max-h-full max-w-full rounded-md object-contain"
draggable={false}
/>
) : (
<div className="flex flex-col items-center gap-3 text-center">
<FileText className="size-16 text-muted-foreground" aria-hidden />
<div>
<p className="font-medium break-all">{item.name}</p>
<p className="text-sm text-muted-foreground">
{t.pdf} · {formatFileSize(item.size)}
</p>
</div>
<Button asChild>
<a href={item.url} target="_blank" rel="noopener">
<ExternalLink />
{t.open}
</a>
</Button>
</div>
)}
</CarouselItem>
))}
</CarouselContent>
{items.length > 1 ? (
<>
<CarouselPrevious className="left-3" aria-label={t.previous} />
<CarouselNext className="right-3" aria-label={t.next} />
</>
) : null}
</Carousel>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,327 @@
import { readFile, rm } from "node:fs/promises";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
getTransaction,
createTransaction,
updateTransaction,
setTransactionDeleted,
} from "@/features/transactions/service";
import { env } from "@/lib/env";
import { getStorage } from "@/lib/storage";
import { AccountModel } from "@/models/account";
import { AttachmentModel } from "@/models/attachment";
import { CategoryModel } from "@/models/category";
import { TransactionModel } from "@/models/transaction";
import type { TransactionForm } from "@/shared/schemas/transaction";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
purgeAttachments,
readAttachmentForUser,
uploadAttachment,
type AttachmentInfo,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let annemler = "";
let ayse = "";
let emre = "";
let zeynep = "";
let fatma = "";
beforeAll(async () => {
await rm(env().UPLOAD_DIR, { recursive: true, force: true });
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
annemler = ids.household("Annemler");
ayse = ids.user("ayse");
emre = ids.user("emre");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
});
afterAll(async () => {
await teardownDb();
await rm(env().UPLOAD_DIR, { recursive: true, force: true });
});
const acc = (name: string, householdId = cakir) =>
AccountModel.findOne({ householdId, name })
.lean()
.then((a) => String(a!._id));
const cat = (key: string) =>
CategoryModel.findOne({ householdId: cakir, defaultKey: key })
.lean()
.then((c) => String(c!._id));
async function form(over: Partial<TransactionForm>): Promise<TransactionForm> {
return {
type: "expense",
amount: 100_00,
date: "2026-06-15",
accountId: await acc("Cüzdan"),
toAccountId: null,
categoryId: await cat("market"),
splits: [],
payee: "",
description: "",
tags: [],
attachmentIds: [],
installments: 1,
...over,
};
}
const png = (w = 40, h = 30) =>
sharp({ create: { width: w, height: h, channels: 3, background: "#3366cc" } })
.png()
.toBuffer();
const pdf = Buffer.from("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n", "latin1");
async function upload(userId: string, bytes: Buffer, name = "fis.png", householdId = cakir) {
const r = await uploadAttachment(userId, householdId, { name, bytes });
if (!r.ok) throw new Error(r.error);
return r.data;
}
describe("yükleme ve dönüştürme", () => {
it("JPEG: EXIF/GPS silinir, yön uygulanır, 2000 px'e küçültülür, WebP + küçük resim", async () => {
const jpeg = await sharp({
create: { width: 3000, height: 1200, channels: 3, background: "#cc3333" },
})
.jpeg()
.withMetadata({ orientation: 6 })
.withExif({ IFD0: { Copyright: "gizli", Artist: "Ayşe" } })
.toBuffer();
expect((await sharp(jpeg).metadata()).exif).toBeDefined();
const info = await upload(ayse, jpeg, "../../etc/fiş.JPG");
expect(info.kind).toBe("image");
expect(info.name).toBe("fiş.JPG");
// Yön 6 (90°): 3000x1200 → 1200x3000 → en fazla 2000: 800x2000
expect([info.width, info.height]).toEqual([800, 2000]);
const doc = await AttachmentModel.findById(info.id).lean();
expect(doc!.mimeType).toBe("image/webp");
expect(doc!.ownerId).toBeNull();
const stored = await getStorage().get(doc!.storageKey);
const meta = await sharp(stored!).metadata();
expect(meta.format).toBe("webp");
expect(meta.exif).toBeUndefined();
expect(meta.orientation).toBeUndefined();
const thumb = await sharp((await getStorage().get(doc!.thumbKey!))!).metadata();
expect(Math.max(thumb.width!, thumb.height!)).toBe(320);
});
it("PNG ve PDF kabul edilir; uzantıya değil içeriğe bakılır", async () => {
expect((await upload(ayse, await png(), "resim.pdf")).kind).toBe("image");
const p = await upload(ayse, pdf, "bordro.jpg");
expect(p.kind).toBe("pdf");
expect(p.thumbUrl).toBeNull();
});
it("desteklenmeyen, bozuk ve boş dosyalar reddedilir", async () => {
const text = await uploadAttachment(ayse, cakir, {
name: "fis.jpg",
bytes: Buffer.from("<script>alert(1)</script>"),
});
expect(text.ok).toBe(false);
const broken = await uploadAttachment(ayse, cakir, {
name: "x.jpg",
bytes: Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 4, 5, 6]),
});
expect(broken).toMatchObject({ ok: false });
const fakePdf = await uploadAttachment(ayse, cakir, {
name: "x.pdf",
bytes: Buffer.from("%PDF-1.4 yarım"),
});
expect(fakePdf.ok).toBe(false);
expect((await uploadAttachment(ayse, cakir, { name: "x", bytes: Buffer.alloc(0) })).ok).toBe(
false,
);
});
it("boyut sınırı uygulanır", async () => {
const big = Buffer.alloc(env().MAX_UPLOAD_MB * 1024 * 1024 + 1);
big.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
expect((await uploadAttachment(ayse, cakir, { name: "b.png", bytes: big })).ok).toBe(false);
});
it("izleyici ve hane dışı kullanıcı yükleyemez", async () => {
expect((await uploadAttachment(zeynep, cakir, { name: "a.png", bytes: await png() })).ok).toBe(
false,
);
expect((await uploadAttachment(fatma, cakir, { name: "a.png", bytes: await png() })).ok).toBe(
false,
);
});
it.skipIf(!process.env.HEIC_SAMPLE)("HEIC (iPhone) WebP'ye dönüştürülür", async () => {
const heic = await readFile(process.env.HEIC_SAMPLE!);
const info = await upload(ayse, heic, "IMG_0001.HEIC");
expect(info.kind).toBe("image");
const doc = await AttachmentModel.findById(info.id).lean();
expect(doc!.sourceType).toBe("heic");
expect((await sharp((await getStorage().get(doc!.storageKey))!).metadata()).format).toBe(
"webp",
);
});
});
describe("işleme bağlama", () => {
let a1: AttachmentInfo;
let a2: AttachmentInfo;
let a3: AttachmentInfo;
let txId = "";
it("gider işlemine görseller bağlanır, sıra korunur", async () => {
a1 = await upload(ayse, await png(10, 10));
a2 = await upload(ayse, await png(20, 10));
a3 = await upload(ayse, await png(30, 10));
const r = await createTransaction(
ayse,
cakir,
await form({ attachmentIds: [a2.id, a1.id, a3.id] }),
);
expect(r.ok).toBe(true);
txId = r.ok ? r.data.id : "";
const detail = await getTransaction(ayse, cakir, txId);
expect(detail!.attachmentCount).toBe(3);
expect(detail!.attachments.map((a) => a.id)).toEqual([a2.id, a1.id, a3.id]);
});
it("gider işlemine PDF eklenemez; gelirde eklenir; hata olursa işlem oluşmaz", async () => {
const p = await upload(ayse, pdf, "bordro.pdf");
const before = await TransactionModel.countDocuments({ householdId: cakir });
const bad = await createTransaction(ayse, cakir, await form({ attachmentIds: [p.id] }));
expect(bad).toMatchObject({ ok: false });
expect(await TransactionModel.countDocuments({ householdId: cakir })).toBe(before);
expect((await AttachmentModel.findById(p.id).lean())!.ownerId).toBeNull();
const good = await createTransaction(
ayse,
cakir,
await form({
type: "income",
categoryId: await cat("maas"),
accountId: await acc("Ziraat Maaş"),
attachmentIds: [p.id],
}),
);
expect(good.ok).toBe(true);
});
it("başkasının bekleyen eki, başka hanenin eki veya başka işlemin eki bağlanamaz", async () => {
const emres = await upload(emre, await png());
expect(
(await createTransaction(ayse, cakir, await form({ attachmentIds: [emres.id] }))).ok,
).toBe(false);
const other = await upload(fatma, await png(), "x.png", annemler);
expect(
(await createTransaction(ayse, cakir, await form({ attachmentIds: [other.id] }))).ok,
).toBe(false);
// a1 zaten txId'ye bağlı: başka işleme taşınamaz.
expect((await createTransaction(ayse, cakir, await form({ attachmentIds: [a1.id] }))).ok).toBe(
false,
);
});
it("kayıt başına en fazla 10 ek", async () => {
const many = [];
for (let i = 0; i < 11; i++) many.push((await upload(emre, await png(5 + i, 5))).id);
expect((await createTransaction(emre, cakir, await form({ attachmentIds: many }))).ok).toBe(
false,
);
expect(
(await createTransaction(emre, cakir, await form({ attachmentIds: many.slice(0, 10) }))).ok,
).toBe(true);
});
it("düzenlemede çıkarılan ek kayıttan düşer, yeni ek eklenir (başka üye de düzenleyebilir)", async () => {
const a4 = await upload(emre, await png(44, 10));
const detail = await getTransaction(emre, cakir, txId);
const r = await updateTransaction(
emre,
cakir,
txId,
await form({
accountId: detail!.account.id,
categoryId: detail!.categoryId,
attachmentIds: [a3.id, a4.id],
}),
);
expect(r.ok).toBe(true);
const after = await getTransaction(ayse, cakir, txId);
expect(after!.attachments.map((a) => a.id)).toEqual([a3.id, a4.id]);
expect(after!.attachmentCount).toBe(2);
expect((await AttachmentModel.findById(a1.id).lean())!.deletedAt).toBeInstanceOf(Date);
expect(await readAttachmentForUser(ayse, a1.id, "full")).toBeNull();
});
it("dosya erişimi: hane üyeleri (izleyici dahil) okur; hane dışı ve silinmiş işlem okuyamaz", async () => {
const file = await readAttachmentForUser(zeynep, a3.id, "thumb");
expect(file?.mimeType).toBe("image/webp");
expect(await readAttachmentForUser(fatma, a3.id, "full")).toBeNull();
expect(await readAttachmentForUser(ayse, "not-an-id", "full")).toBeNull();
await setTransactionDeleted(ayse, cakir, txId, true);
expect(await readAttachmentForUser(ayse, a3.id, "full")).toBeNull();
await setTransactionDeleted(ayse, cakir, txId, false);
expect(await readAttachmentForUser(ayse, a3.id, "full")).not.toBeNull();
});
it("bekleyen ek yalnızca yükleyene görünür", async () => {
const pending = await upload(ayse, await png());
expect(await readAttachmentForUser(ayse, pending.id, "full")).not.toBeNull();
expect(await readAttachmentForUser(emre, pending.id, "full")).toBeNull();
});
it("gizli hesaptaki işlemin eki hesap sahibi dışındakilere kapalı", async () => {
const img = await upload(ayse, await png());
const r = await createTransaction(
ayse,
cakir,
await form({ accountId: await acc("Ayşe — Pluxee"), attachmentIds: [img.id] }),
);
expect(r.ok).toBe(true);
expect(await readAttachmentForUser(ayse, img.id, "full")).not.toBeNull();
expect(await readAttachmentForUser(emre, img.id, "full")).toBeNull();
});
});
describe("temizlik", () => {
it("eski bekleyen ekler, çıkarılmış ekler ve 30 günden önce silinmiş işlemlerin ekleri silinir", async () => {
const pending = await upload(ayse, await png());
const old = new Date(Date.now() - 25 * 60 * 60 * 1000);
await AttachmentModel.collection.updateOne(
{ _id: (await AttachmentModel.findById(pending.id))!._id },
{ $set: { createdAt: old } },
);
const kept = await upload(ayse, await png());
const r = await createTransaction(ayse, cakir, await form({ attachmentIds: [kept.id] }));
const tx = r.ok ? r.data.id : "";
await setTransactionDeleted(ayse, cakir, tx, true);
const keptDoc = (await AttachmentModel.findById(kept.id).lean())!;
// Silme 31 gün önce yapılmış gibi.
const now = new Date(Date.now() + 31 * 24 * 60 * 60 * 1000);
const pendingDoc = (await AttachmentModel.findById(pending.id).lean())!;
const { removed } = await purgeAttachments(now);
expect(removed).toBeGreaterThan(0);
expect(await AttachmentModel.findById(pending.id)).toBeNull();
expect(await getStorage().get(pendingDoc.storageKey)).toBeNull();
expect(await AttachmentModel.findById(kept.id)).toBeNull();
expect(await getStorage().get(keptDoc.storageKey)).toBeNull();
expect((await TransactionModel.findById(tx).lean())!.attachmentCount).toBe(0);
});
it("taze bekleyen ekler ve bağlı ekler korunur", async () => {
const fresh = await upload(ayse, await png());
const linked = await upload(ayse, await png());
await createTransaction(ayse, cakir, await form({ attachmentIds: [linked.id] }));
await purgeAttachments();
expect(await AttachmentModel.findById(fresh.id)).not.toBeNull();
expect(await AttachmentModel.findById(linked.id)).not.toBeNull();
});
});
+326
View File
@@ -0,0 +1,326 @@
import "server-only";
import { randomBytes } from "node:crypto";
import { Types, type ClientSession } from "mongoose";
import { resolveMembership } from "@/features/households/service";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { connectMongoose } from "@/lib/db/mongoose";
import { env } from "@/lib/env";
import { toObjectId } from "@/lib/object-id";
import { getStorage } from "@/lib/storage";
import { isPlausiblePdf, processImage, UnreadableImageError } from "@/lib/uploads/process";
import { AccountModel } from "@/models/account";
import { AttachmentModel, type AttachmentOwnerType } from "@/models/attachment";
import { TransactionModel } from "@/models/transaction";
import {
detectFileType,
MAX_ATTACHMENTS_PER_RECORD,
type AttachmentKind,
} from "@/shared/attachments";
const t = tr.attachments;
/** Bir kullanıcının bir hanede aynı anda bekletebileceği (kayda bağlanmamış) ek sayısı. */
export const MAX_PENDING_PER_USER = 30;
/** Bağlanmamış eklerin ömrü. */
export const PENDING_TTL_MS = 24 * 60 * 60 * 1000;
/** Silinen kayıt/ek dosyalarının fiziksel olarak silinmeden önce bekleme süresi (geri alma payı). */
export const SOFT_DELETE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
export type AttachmentInfo = {
id: string;
kind: AttachmentKind;
name: string;
size: number;
width: number | null;
height: number | null;
url: string;
thumbUrl: string | null;
};
type LeanAttachment = {
_id: Types.ObjectId;
kind: AttachmentKind;
originalName?: string | null;
size: number;
width?: number | null;
height?: number | null;
thumbKey?: string | null;
};
export function toAttachmentInfo(a: LeanAttachment): AttachmentInfo {
const id = String(a._id);
return {
id,
kind: a.kind,
name: a.originalName || (a.kind === "pdf" ? "belge.pdf" : "gorsel.webp"),
size: a.size,
width: a.width ?? null,
height: a.height ?? null,
url: `/api/files/${id}`,
thumbUrl: a.thumbKey ? `/api/files/${id}?v=thumb` : null,
};
}
/** Kullanıcının verdiği dosya adını güvenli hale getirir (yol, kontrol karakteri, uzunluk). */
export function sanitizeFileName(name: string): string {
const base = name.split(/[\\/]/).pop() ?? "";
return base
.replace(/[\u0000-\u001f\u007f"<>]/g, "")
.trim()
.slice(0, 120);
}
function newKey(): string {
const id = randomBytes(16).toString("hex");
return `${id.slice(0, 2)}/${id}`;
}
// --- Yükleme ---
export async function uploadAttachment(
userId: string,
householdId: string,
file: { name: string; bytes: Buffer },
): Promise<ActionResult<AttachmentInfo>> {
const m = await resolveMembership(userId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const maxBytes = env().MAX_UPLOAD_MB * 1024 * 1024;
if (file.bytes.length === 0) return fail(t.errors.empty);
if (file.bytes.length > maxBytes) return fail(t.errors.tooLarge(env().MAX_UPLOAD_MB));
const type = detectFileType(file.bytes);
if (!type) return fail(t.errors.unsupported);
const uploader = new Types.ObjectId(userId);
const pending = await AttachmentModel.countDocuments({
householdId: m.householdId,
ownerId: null,
uploadedBy: uploader,
deletedAt: null,
});
if (pending >= MAX_PENDING_PER_USER) return fail(t.errors.tooManyPending);
const key = newKey();
const storage = getStorage();
let doc: Record<string, unknown>;
if (type === "pdf") {
if (!isPlausiblePdf(file.bytes)) return fail(t.errors.unreadable);
await storage.put(`${key}.pdf`, file.bytes);
doc = {
kind: "pdf",
mimeType: "application/pdf",
size: file.bytes.length,
storageKey: `${key}.pdf`,
thumbKey: null,
};
} else {
let processed;
try {
processed = await processImage(file.bytes, type);
} catch (error) {
if (error instanceof UnreadableImageError) return fail(t.errors.unreadable);
throw error;
}
await storage.put(`${key}.webp`, processed.main);
await storage.put(`${key}.thumb.webp`, processed.thumb);
doc = {
kind: "image",
mimeType: "image/webp",
size: processed.main.length,
width: processed.width,
height: processed.height,
storageKey: `${key}.webp`,
thumbKey: `${key}.thumb.webp`,
};
}
const created = await AttachmentModel.create({
...doc,
householdId: m.householdId,
sourceType: type,
originalName: sanitizeFileName(file.name),
originalSize: file.bytes.length,
uploadedBy: uploader,
});
return ok(toAttachmentInfo(created.toObject() as LeanAttachment));
}
// --- Kayda bağlama ---
/**
* Kaydın ek listesini `ids` ile eşitler (sıra korunur). Yeni kimlikler, bu kullanıcının bu hanede
* yüklediği bekleyen ekler olmalı; listede olmayan mevcut ekler kayıttan çıkarılır (`deletedAt`).
* Kayıttaki güncel ek sayısını döner. Transaction içinde çağrılmalı (session).
*/
export async function syncOwnerAttachments(opts: {
householdId: Types.ObjectId;
actorId: string;
ownerType: AttachmentOwnerType;
ownerId: Types.ObjectId;
ids: string[];
allowedKinds: readonly AttachmentKind[];
session?: ClientSession;
}): Promise<ActionResult<number>> {
const { householdId, ownerType, ownerId, session } = opts;
const unique = [...new Set(opts.ids)];
if (unique.length > MAX_ATTACHMENTS_PER_RECORD)
return fail(t.errors.tooMany(MAX_ATTACHMENTS_PER_RECORD));
const objectIds = unique.map(toObjectId);
if (objectIds.some((id) => !id)) return fail(t.errors.invalid);
const current = await AttachmentModel.find(
{ householdId, ownerType, ownerId, deletedAt: null },
{ _id: 1 },
{ session },
).lean();
const currentIds = new Set(current.map((a) => String(a._id)));
const newIds = objectIds.filter((id) => !currentIds.has(String(id))) as Types.ObjectId[];
const candidates = await AttachmentModel.find(
{ _id: { $in: objectIds }, householdId, deletedAt: null },
{ kind: 1, ownerId: 1, uploadedBy: 1 },
{ session },
).lean();
const byId = new Map(candidates.map((a) => [String(a._id), a]));
for (const id of newIds) {
const a = byId.get(String(id));
// Yalnızca kendi yüklediği, henüz hiçbir kayda bağlanmamış ekler bağlanabilir.
if (!a || a.ownerId || String(a.uploadedBy) !== opts.actorId) return fail(t.errors.invalid);
}
for (const id of objectIds) {
const a = byId.get(String(id));
if (a && !opts.allowedKinds.includes(a.kind)) return fail(t.errors.kindNotAllowed);
}
const removed = current.filter((a) => !objectIds.some((id) => id!.equals(a._id)));
if (removed.length) {
await AttachmentModel.updateMany(
{ _id: { $in: removed.map((a) => a._id) }, householdId },
{ $set: { deletedAt: new Date() } },
{ session },
);
}
if (objectIds.length) {
await AttachmentModel.bulkWrite(
objectIds.map((id, order) => ({
updateOne: {
filter: { _id: id!, householdId },
update: { $set: { ownerType, ownerId, order } },
},
})),
{ session },
);
}
return ok(objectIds.length);
}
export async function listOwnerAttachments(
householdId: Types.ObjectId,
ownerType: AttachmentOwnerType,
ownerId: Types.ObjectId,
): Promise<AttachmentInfo[]> {
const rows = await AttachmentModel.find({ householdId, ownerType, ownerId, deletedAt: null })
.sort({ order: 1, createdAt: 1 })
.lean();
return rows.map((a) => toAttachmentInfo(a as unknown as LeanAttachment));
}
// --- Servis etme ---
export type AttachmentFile = {
bytes: Buffer;
mimeType: string;
name: string;
};
/**
* Yetki kontrollü dosya okuma: kullanıcı ekin hanesinin üyesi olmalı; bekleyen ek yalnızca
* yükleyene; işleme bağlı ek, işlem kullanıcıya görünürse (silinmemiş ve başkasının gizli
* hesabına dokunmuyorsa). Erişim yoksa null (çağıran 404 döner; varlık sızdırılmaz).
*/
export async function readAttachmentForUser(
userId: string,
attachmentId: string,
variant: "full" | "thumb",
): Promise<AttachmentFile | null> {
const id = toObjectId(attachmentId);
if (!id) return null;
await connectMongoose();
const a = await AttachmentModel.findOne({ _id: id, deletedAt: null }).lean();
if (!a) return null;
const m = await resolveMembership(userId, String(a.householdId), "viewer");
if (!m) return null;
if (!a.ownerId) {
if (String(a.uploadedBy) !== userId) return null;
} else if (a.ownerType === "transaction") {
const tx = await TransactionModel.findOne(
{ _id: a.ownerId, householdId: a.householdId, deletedAt: null },
{ accountId: 1, toAccountId: 1 },
).lean();
if (!tx) return null;
const hidden = await AccountModel.exists({
_id: { $in: [tx.accountId, tx.toAccountId].filter(Boolean) },
householdId: a.householdId,
isPrivate: true,
ownerUserId: { $ne: new Types.ObjectId(userId) },
});
if (hidden) return null;
} else {
return null;
}
const key = variant === "thumb" && a.thumbKey ? a.thumbKey : a.storageKey;
const bytes = await getStorage().get(key);
if (!bytes) return null;
return {
bytes,
mimeType: variant === "thumb" && a.thumbKey ? "image/webp" : a.mimeType,
name: a.originalName ?? "",
};
}
// --- Temizlik ---
/**
* Fiziksel temizlik (Bölüm 7.4): 24 saatten eski bekleyen ekler, 30 günden önce çıkarılmış
* ekler ve 30 günden önce silinmiş işlemlerin ekleri. Dosyalar silinir, kayıtlar kaldırılır.
*/
export async function purgeAttachments(now = new Date()): Promise<{ removed: number }> {
await connectMongoose();
const retentionCutoff = new Date(now.getTime() - SOFT_DELETE_RETENTION_MS);
const pendingCutoff = new Date(now.getTime() - PENDING_TTL_MS);
const deletedTx = await TransactionModel.find(
{ deletedAt: { $ne: null, $lt: retentionCutoff }, attachmentCount: { $gt: 0 } },
{ _id: 1 },
).lean();
const victims = await AttachmentModel.find(
{
$or: [
{ ownerId: null, createdAt: { $lt: pendingCutoff } },
{ deletedAt: { $ne: null, $lt: retentionCutoff } },
...(deletedTx.length
? [{ ownerType: "transaction" as const, ownerId: { $in: deletedTx.map((d) => d._id) } }]
: []),
],
},
{ storageKey: 1, thumbKey: 1 },
).lean();
const storage = getStorage();
for (const v of victims) {
await storage.delete(v.storageKey);
if (v.thumbKey) await storage.delete(v.thumbKey);
await AttachmentModel.deleteOne({ _id: v._id });
}
if (deletedTx.length) {
await TransactionModel.updateMany(
{ _id: { $in: deletedTx.map((d) => d._id) } },
{ $set: { attachmentCount: 0 } },
);
}
return { removed: victims.length };
}
+137
View File
@@ -0,0 +1,137 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { confirmOccurrenceSchema, recurringRuleFormSchema } from "@/shared/schemas/recurring";
import {
confirmOccurrence,
createRule,
deleteRule,
getRuleForm,
postponeOccurrence,
skipOccurrence,
updateRule,
type RuleFormValues,
} from "./service";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
const optionalId = idSchema.optional();
const scopeSchema = z.enum(["all", "following"]);
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
function fieldErrors(error: z.ZodError) {
const out: Record<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
function revalidate() {
revalidatePath("/", "layout");
}
export async function createRuleAction(input: unknown): Promise<ActionResult<{ id: string }>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = recurringRuleFormSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await createRule(ctx.user.id, ctx.household.id, parsed.data);
if (result.ok) revalidate();
return result.ok ? ok({ id: result.data.id }) : result;
}
export async function updateRuleAction(
ruleId: unknown,
input: unknown,
scope: unknown,
fromTransactionId?: unknown,
): Promise<ActionResult<{ id: string }>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(ruleId);
const s = scopeSchema.safeParse(scope);
const from = optionalId.safeParse(fromTransactionId ?? undefined);
if (!id.success || !s.success || !from.success) return fail(tr.common.notFound);
const parsed = recurringRuleFormSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await updateRule(
ctx.user.id,
ctx.household.id,
id.data,
parsed.data,
s.data,
from.data,
);
if (result.ok) revalidate();
return result;
}
export async function deleteRuleAction(
ruleId: unknown,
scope: unknown,
fromTransactionId?: unknown,
): Promise<ActionResult<{ removed: number }>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(ruleId);
const s = scopeSchema.safeParse(scope);
const from = optionalId.safeParse(fromTransactionId ?? undefined);
if (!id.success || !s.success || !from.success) return fail(tr.common.notFound);
const result = await deleteRule(ctx.user.id, ctx.household.id, id.data, s.data, from.data);
if (result.ok) revalidate();
return result;
}
export async function getRuleFormAction(ruleId: unknown): Promise<ActionResult<RuleFormValues>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(ruleId);
if (!id.success) return fail(tr.common.notFound);
const values = await getRuleForm(ctx.user.id, ctx.household.id, id.data);
return values ? ok(values) : fail(tr.common.notFound);
}
export async function confirmOccurrenceAction(
transactionId: unknown,
input: unknown,
): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(transactionId);
if (!id.success) return fail(tr.common.notFound);
const parsed = confirmOccurrenceSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await confirmOccurrence(ctx.user.id, ctx.household.id, id.data, parsed.data);
if (result.ok) revalidate();
return result;
}
export async function skipOccurrenceAction(transactionId: unknown): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(transactionId);
if (!id.success) return fail(tr.common.notFound);
const result = await skipOccurrence(ctx.user.id, ctx.household.id, id.data);
if (result.ok) revalidate();
return result;
}
export async function postponeOccurrenceAction(
transactionId: unknown,
date: unknown,
): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(transactionId);
const d = ymd.safeParse(date);
if (!id.success) return fail(tr.common.notFound);
if (!d.success) return fail(tr.validation.date, { date: tr.validation.date });
const result = await postponeOccurrence(ctx.user.id, ctx.household.id, id.data, d.data);
if (result.ok) revalidate();
return result;
}
@@ -0,0 +1,32 @@
import { TriangleAlert } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { tr } from "@/i18n/tr";
import { countOverdue, ensureHorizon } from "../service";
import { toObjectId } from "@/lib/object-id";
/** Dashboard: vadesi geçmiş onaylanmamış planlı işlemler uyarısı (Bölüm 6.7). */
export async function OverdueBanner({
userId,
householdId,
}: {
userId: string;
householdId: string;
}) {
const hid = toObjectId(householdId);
if (hid) await ensureHorizon(hid);
const n = await countOverdue(userId, householdId);
if (!n) return null;
return (
<div
role="status"
className="flex flex-wrap items-center gap-3 rounded-xl border border-expense/30 bg-expense/10 p-3 text-sm"
>
<TriangleAlert className="size-4 shrink-0 text-expense" aria-hidden />
<span className="flex-1">{tr.recurring.overdueBanner(n)}</span>
<Button asChild size="sm" variant="outline">
<Link href="/planli">{tr.recurring.overdueLink}</Link>
</Button>
</div>
);
}
@@ -0,0 +1,508 @@
"use client";
import {
CalendarClock,
Check,
EllipsisVertical,
Loader2,
Pencil,
Plus,
SkipForward,
Trash2,
Repeat,
} from "lucide-react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { MoneyInput } from "@/components/money-input";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { useQuickAdd } from "@/components/layout/quick-add";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getTransactionAction } from "@/features/transactions/actions";
import {
Amount,
RowIcon,
rowSubtitle,
rowTitle,
} from "@/features/transactions/components/transaction-parts";
import { tr } from "@/i18n/tr";
import { todayIstanbul } from "@/shared/dates";
import { formatDate, formatMoney } from "@/shared/format";
import { formatMoneyInput } from "@/shared/money";
import { addDays } from "@/shared/recurrence";
import {
confirmOccurrenceAction,
deleteRuleAction,
getRuleFormAction,
postponeOccurrenceAction,
skipOccurrenceAction,
} from "../actions";
import type { PlannedItem, PlannedOverview, RuleView } from "../service";
import { RuleFormDialog, type RuleFormMode } from "./rule-form";
const t = tr.recurring;
const dateLabel = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
type Pending =
| { kind: "confirm"; item: PlannedItem }
| { kind: "postpone"; item: PlannedItem }
| { kind: "scope"; item: PlannedItem }
| { kind: "delete"; ruleId: string; item?: PlannedItem }
| null;
export function PlannedView({ data, canWrite }: { data: PlannedOverview; canWrite: boolean }) {
const [tab, setTab] = useState(data.overdue.length ? "overdue" : "upcoming");
const [pending, setPending] = useState<Pending>(null);
const [ruleForm, setRuleForm] = useState<RuleFormMode | null>(null);
const [, startTransition] = useTransition();
const { openTransactionForm } = useQuickAdd();
function skip(item: PlannedItem) {
startTransition(async () => {
const r = await skipOccurrenceAction(item.id);
if (r.ok) toast.success(t.toast.skipped);
else toast.error(r.error);
});
}
async function editScope(item: PlannedItem, scope: "one" | "following" | "all") {
setPending(null);
if (scope === "one" || !item.recurringRuleId) {
const d = await getTransactionAction(item.id);
if (d.ok) openTransactionForm({ kind: "edit", transaction: d.data });
else toast.error(d.error);
return;
}
const rule = await getRuleFormAction(item.recurringRuleId);
if (!rule.ok) return void toast.error(rule.error);
const [pos, total] = (item.seriesPosition ?? "").split("/").map(Number);
setRuleForm({
kind: "edit",
rule: rule.data,
scope,
fromTransactionId: scope === "following" ? item.id : undefined,
fromDate: scope === "following" ? item.date : undefined,
remainingCount: pos && total ? total - pos + 1 : null,
});
}
async function editRule(rule: RuleView) {
const r = await getRuleFormAction(rule.id);
if (r.ok) setRuleForm({ kind: "edit", rule: r.data, scope: "all" });
else toast.error(r.error);
}
const itemList = (items: PlannedItem[], empty: string) =>
items.length === 0 ? (
<Card>
<CardHeader>
<CardDescription>{empty}</CardDescription>
</CardHeader>
</Card>
) : (
<ul className="divide-y rounded-xl border bg-card">
{items.map((item) => (
<li key={item.id} className="flex items-center gap-3 p-3">
<RowIcon item={item} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate font-medium">{rowTitle(item)}</span>
{item.seriesPosition ? (
<Badge variant="outline" className="hidden sm:inline-flex">
{item.seriesPosition}
</Badge>
) : null}
{item.isEstimate ? (
<Badge variant="secondary" className="hidden sm:inline-flex">
{t.estimate}
</Badge>
) : null}
</span>
<span className="block truncate text-xs text-muted-foreground">
{/* Mobilde rozetler alt satırda (başlık daralmasın). */}
<span className="sm:hidden">
{item.seriesPosition ? `${item.seriesPosition} · ` : ""}
{item.isEstimate ? `${t.estimate} · ` : ""}
</span>
{dateLabel(item.date)} · {rowSubtitle(item)}
</span>
</span>
<Amount type={item.type} amountMinor={item.amountMinor} />
{canWrite ? (
<span className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
onClick={() => setPending({ kind: "confirm", item })}
>
<Check />
<span className="hidden sm:inline">{t.actions.confirm}</span>
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="icon-sm" variant="ghost" aria-label={t.actions.more}>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setPending({ kind: "postpone", item })}>
<CalendarClock />
{t.actions.postpone}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => skip(item)}>
<SkipForward />
{t.actions.skip}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
item.recurringRuleId
? setPending({ kind: "scope", item })
: void editScope(item, "one")
}
>
<Pencil />
{t.actions.edit}
</DropdownMenuItem>
{item.recurringRuleId ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() =>
setPending({ kind: "delete", ruleId: item.recurringRuleId!, item })
}
>
<Trash2 />
{t.actions.delete}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</span>
) : null}
</li>
))}
</ul>
);
return (
<div className="flex flex-col gap-4">
{canWrite ? (
<div className="flex justify-end">
<Button onClick={() => setRuleForm({ kind: "create" })}>
<Plus />
{t.newRule}
</Button>
</div>
) : null}
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="overdue">
{t.tabs.overdue}
{data.overdue.length ? (
<Badge variant="destructive" className="ml-1">
{data.overdue.length}
</Badge>
) : null}
</TabsTrigger>
<TabsTrigger value="upcoming">{t.tabs.upcoming}</TabsTrigger>
<TabsTrigger value="rules">{t.tabs.rules}</TabsTrigger>
</TabsList>
<TabsContent value="overdue" className="mt-3">
{itemList(data.overdue, t.empty.overdue)}
</TabsContent>
<TabsContent value="upcoming" className="mt-3">
{itemList(data.upcoming, t.empty.upcoming)}
</TabsContent>
<TabsContent value="rules" className="mt-3">
{data.rules.length === 0 ? (
<Card>
<CardHeader>
<CardDescription>{t.empty.rules}</CardDescription>
</CardHeader>
</Card>
) : (
<ul className="divide-y rounded-xl border bg-card">
{data.rules.map((rule) => (
<li key={rule.id} className="flex items-center gap-3 p-3">
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Repeat className="size-4.5" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate font-medium">{rule.title}</span>
{rule.isEstimate ? <Badge variant="secondary">{t.estimate}</Badge> : null}
</span>
<span className="block truncate text-xs text-muted-foreground">
{rule.recurrence} ·{" "}
{rule.toAccount ? `${rule.account} → ${rule.toAccount}` : rule.account}
{rule.nextDate ? ` · ${t.next(dateLabel(rule.nextDate))}` : ""}
{rule.remaining !== null
? ` · ${t.remaining(rule.remaining)}`
: ` · ${t.endless}`}
</span>
</span>
<Amount type={rule.type} amountMinor={rule.amountMinor} />
{canWrite ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="icon-sm" variant="ghost" aria-label={t.actions.more}>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => void editRule(rule)}>
<Pencil />
{t.actions.edit}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => setPending({ kind: "delete", ruleId: rule.id })}
>
<Trash2 />
{t.actions.delete}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</li>
))}
</ul>
)}
</TabsContent>
</Tabs>
<ConfirmDialog
item={pending?.kind === "confirm" ? pending.item : null}
onClose={() => setPending(null)}
/>
<PostponeDialog
item={pending?.kind === "postpone" ? pending.item : null}
onClose={() => setPending(null)}
/>
<ScopeDialog
item={pending?.kind === "scope" ? pending.item : null}
onClose={() => setPending(null)}
onPick={(item, scope) => void editScope(item, scope)}
/>
<DeleteDialog
target={pending?.kind === "delete" ? pending : null}
onClose={() => setPending(null)}
/>
<RuleFormDialog mode={ruleForm} onOpenChange={(o) => !o && setRuleForm(null)} />
</div>
);
}
function ConfirmDialog({ item, onClose }: { item: PlannedItem | null; onClose: () => void }) {
return item ? <ConfirmForm key={item.id} item={item} onClose={onClose} /> : null;
}
function ConfirmForm({ item, onClose }: { item: PlannedItem; onClose: () => void }) {
const today = todayIstanbul();
const [amount, setAmount] = useState(formatMoneyInput(item.amountMinor));
// Gelecekteki kaydı erken onaylarken bugünün tarihi; geçmiştekini kendi tarihiyle.
const [date, setDate] = useState(item.date > today ? today : item.date);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [busy, setBusy] = useState(false);
async function submit() {
setBusy(true);
const r = await confirmOccurrenceAction(item.id, { amount, date });
setBusy(false);
if (r.ok) {
toast.success(t.toast.confirmed);
onClose();
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
}
return (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={`${t.confirmTitle}: ${rowTitle(item)}`}
description={t.confirmDescription}
footer={
<>
<Button variant="outline" onClick={onClose}>
{tr.common.cancel}
</Button>
<Button onClick={submit} disabled={busy}>
{busy ? <Loader2 className="animate-spin" /> : <Check />}
{t.actions.confirm}
</Button>
</>
}
>
<FieldGroup>
<Field data-invalid={Boolean(errors.amount)}>
<FieldLabel htmlFor="confirm-amount">{tr.transactions.fields.amount}</FieldLabel>
<MoneyInput id="confirm-amount" value={amount} onChange={setAmount} autoFocus />
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.date)}>
<FieldLabel htmlFor="confirm-date">{tr.transactions.fields.date}</FieldLabel>
<Input
id="confirm-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
</Field>
{item.isEstimate ? (
<p className="text-xs text-muted-foreground">
{t.estimate}: {formatMoney(item.amountMinor)}
</p>
) : null}
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</ResponsiveDialog>
);
}
function PostponeDialog({ item, onClose }: { item: PlannedItem | null; onClose: () => void }) {
return item ? <PostponeForm key={item.id} item={item} onClose={onClose} /> : null;
}
function PostponeForm({ item, onClose }: { item: PlannedItem; onClose: () => void }) {
const base = item.date > todayIstanbul() ? item.date : todayIstanbul();
const [date, setDate] = useState(addDays(base, 7));
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit() {
setBusy(true);
const r = await postponeOccurrenceAction(item.id, date);
setBusy(false);
if (r.ok) {
toast.success(t.toast.postponed);
onClose();
} else setError(r.fieldErrors?.date ?? r.error);
}
return (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={`${t.postponeTitle}: ${rowTitle(item)}`}
footer={
<>
<Button variant="outline" onClick={onClose}>
{tr.common.cancel}
</Button>
<Button onClick={submit} disabled={busy}>
{busy ? <Loader2 className="animate-spin" /> : <CalendarClock />}
{t.actions.postpone}
</Button>
</>
}
>
<Field data-invalid={Boolean(error)}>
<FieldLabel htmlFor="postpone-date">{t.postponeDate}</FieldLabel>
<Input
id="postpone-date"
type="date"
value={date}
min={addDays(item.date, 1)}
onChange={(e) => setDate(e.target.value)}
/>
{error ? <FieldError>{error}</FieldError> : null}
</Field>
</ResponsiveDialog>
);
}
function ScopeDialog({
item,
onClose,
onPick,
}: {
item: PlannedItem | null;
onClose: () => void;
onPick: (item: PlannedItem, scope: "one" | "following" | "all") => void;
}) {
return (
<ResponsiveDialog
open={Boolean(item)}
onOpenChange={(o) => !o && onClose()}
title={t.scopeTitle}
>
<div className="flex flex-col gap-2">
{(["one", "following", "all"] as const).map((scope) => (
<button
key={scope}
type="button"
onClick={() => item && onPick(item, scope)}
className="rounded-xl border p-3 text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="block font-medium">{t.scopes[scope]}</span>
<span className="block text-xs text-muted-foreground">{t.scopeHints[scope]}</span>
</button>
))}
</div>
</ResponsiveDialog>
);
}
function DeleteDialog({
target,
onClose,
}: {
target: { ruleId: string; item?: PlannedItem } | null;
onClose: () => void;
}) {
const [busy, setBusy] = useState(false);
async function run(scope: "all" | "following") {
if (!target) return;
setBusy(true);
const r = await deleteRuleAction(target.ruleId, scope, target.item?.id);
setBusy(false);
if (r.ok) {
toast.success(t.toast.deleted);
onClose();
} else toast.error(r.error);
}
return (
<AlertDialog open={Boolean(target)} onOpenChange={(o) => !o && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
<AlertDialogDescription>{t.deleteDescription}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
{target?.item ? (
<Button variant="outline" disabled={busy} onClick={() => run("following")}>
{t.deleteScopes.following}
</Button>
) : null}
<Button variant="destructive" disabled={busy} onClick={() => run("all")}>
{t.deleteScopes.all}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,538 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm, useWatch, type Resolver } from "react-hook-form";
import { toast } from "sonner";
import { MoneyInput } from "@/components/money-input";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
import {
AccountSelect,
CategorySelect,
PayeeInput,
} from "@/features/transactions/components/transaction-form";
import type { TransactionFormOptions } from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import { todayIstanbul } from "@/shared/dates";
import { formatDate } from "@/shared/format";
import { formatMoneyInput } from "@/shared/money";
import {
describeRecurrence,
FREQUENCIES,
listOccurrences,
MONTHLY_MODES,
type Frequency,
type MonthlyMode,
} from "@/shared/recurrence";
import {
END_KINDS,
recurringRuleFormSchema,
type RecurringRuleForm,
} from "@/shared/schemas/recurring";
import { TRANSACTION_TYPES, type TransactionType } from "@/shared/transactions";
import { createRuleAction, updateRuleAction } from "../actions";
import type { RuleFormValues } from "../service";
const t = tr.recurring;
const tf = tr.transactions.fields;
export type RuleFormMode =
| { kind: "create" }
| {
kind: "edit";
rule: RuleFormValues;
scope: "all" | "following";
/** "Bu ve sonrakiler" için başlangıç tekrarı ve onun tarihi / kalan tekrar sayısı. */
fromTransactionId?: string;
fromDate?: string;
remainingCount?: number | null;
};
type FormValues = {
type: TransactionType;
amount: string;
isEstimate: boolean;
accountId: string;
toAccountId: string;
categoryId: string;
payee: string;
description: string;
tags: string;
startDate: string;
frequency: Frequency;
interval: string;
monthlyMode: MonthlyMode;
endKind: (typeof END_KINDS)[number];
count: string;
endDate: string;
};
function toValues(mode: RuleFormMode, options: TransactionFormOptions | null): FormValues {
if (mode.kind === "create") {
return {
type: "expense",
amount: "",
isEstimate: false,
accountId: options?.accounts[0]?.id ?? "",
toAccountId: "",
categoryId: "",
payee: "",
description: "",
tags: "",
startDate: todayIstanbul(),
frequency: "monthly",
interval: "1",
monthlyMode: "day",
endKind: "never",
count: "",
endDate: "",
};
}
const r = mode.rule;
const following = mode.scope === "following" && mode.fromDate;
const count = following ? (mode.remainingCount ?? null) : r.count;
return {
type: r.type,
amount: formatMoneyInput(r.amountMinor),
isEstimate: r.isEstimate,
accountId: r.accountId,
toAccountId: r.toAccountId ?? "",
categoryId: r.categoryId ?? "",
payee: r.payee,
description: r.description,
tags: r.tags.join(", "),
startDate: following ? mode.fromDate! : r.startDate,
frequency: r.frequency,
interval: String(r.interval),
monthlyMode: r.monthlyMode,
endKind: count ? "count" : r.endDate ? "date" : "never",
count: count ? String(count) : "",
endDate: r.endDate ?? "",
};
}
/** Düzenli işlem kuralı formu (oluştur / tümünü düzenle / bu ve sonrakileri düzenle). */
export function RuleFormDialog({
mode,
onOpenChange,
}: {
mode: RuleFormMode | null;
onOpenChange: (open: boolean) => void;
}) {
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const form = useForm<FormValues, unknown, RecurringRuleForm>({
resolver: zodResolver(recurringRuleFormSchema) as unknown as Resolver<
FormValues,
unknown,
RecurringRuleForm
>,
defaultValues: toValues({ kind: "create" }, null),
});
useEffect(() => {
if (!mode) return;
let cancelled = false;
getTransactionFormOptionsAction().then((res) => {
if (cancelled) return;
if (!res.ok) return setLoadError(res.error);
setLoadError(null);
setOptions(res.data);
form.reset(toValues(mode, res.data));
});
return () => {
cancelled = true;
};
}, [mode, form]);
const w = useWatch({ control: form.control });
const type = w.type ?? "expense";
const categories = useMemo(() => {
const kind = type === "income" ? "income" : "expense";
return (options?.categories ?? []).filter((c) => c.kind === kind);
}, [options, type]);
// Önizleme: açıklama ve ilk 3 tarih (istemcide aynı saf fonksiyonla).
const preview = useMemo(() => {
const interval = Math.min(12, Math.max(1, Number(w.interval) || 1));
const r = {
frequency: w.frequency ?? "monthly",
interval,
monthlyMode: w.monthlyMode ?? "day",
startDate: w.startDate ?? todayIstanbul(),
endDate: w.endKind === "date" && w.endDate ? w.endDate : null,
count: w.endKind === "count" && Number(w.count) > 0 ? Number(w.count) : null,
} as const;
if (!/^\d{4}-\d{2}-\d{2}$/.test(r.startDate)) return null;
return {
text: describeRecurrence(r),
dates: listOccurrences(r, "9999-12-31")
.slice(0, 3)
.map((o) => o.date),
};
}, [w]);
function changeType(next: TransactionType) {
form.setValue("type", next);
form.setValue("categoryId", "");
if (next !== "transfer") form.setValue("toAccountId", "");
form.clearErrors();
}
async function onSubmit() {
if (!mode) return;
const values = form.getValues();
const payload = {
...values,
toAccountId: values.type === "transfer" ? values.toAccountId : "",
categoryId: values.type === "transfer" ? "" : values.categoryId,
count: values.endKind === "count" ? values.count : null,
endDate: values.endKind === "date" ? values.endDate : null,
};
const result =
mode.kind === "edit"
? await updateRuleAction(mode.rule.id, payload, mode.scope, mode.fromTransactionId)
: await createRuleAction(payload);
if (result.ok) {
toast.success(mode.kind === "edit" ? t.toast.updated : t.toast.created);
onOpenChange(false);
return;
}
for (const [name, message] of Object.entries(result.fieldErrors ?? {})) {
if (message) form.setError(name as keyof FormValues, { message });
}
if (!result.fieldErrors) form.setError("root", { message: result.error });
}
const loading = Boolean(mode) && !options && !loadError;
const errors = form.formState.errors;
const title = mode?.kind === "edit" ? `${t.editTitle} · ${t.scopes[mode.scope]}` : t.createTitle;
return (
<ResponsiveDialog
open={Boolean(mode)}
onOpenChange={onOpenChange}
title={title}
description={mode?.kind === "edit" ? t.scopeHints[mode.scope] : undefined}
footer={
loadError ? null : (
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
{tr.common.cancel}
</Button>
<Button
type="submit"
form="rule-form"
disabled={form.formState.isSubmitting || loading}
>
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
{tr.common.save}
</Button>
</>
)
}
>
{loadError ? (
<p className="text-sm text-destructive">{loadError}</p>
) : loading || !options ? (
<div className="flex flex-col gap-4" role="status" aria-label={tr.common.loading}>
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : (
<form id="rule-form" onSubmit={form.handleSubmit(onSubmit)} noValidate>
<FieldGroup>
<Tabs value={type} onValueChange={(v) => changeType(v as TransactionType)}>
<TabsList className="w-full">
{TRANSACTION_TYPES.map((x) => (
<TabsTrigger key={x} value={x} className="flex-1">
{tr.transactions.types[x]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Controller
name="amount"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="rule-amount">{tf.amount}</FieldLabel>
<MoneyInput
id="rule-amount"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="isEstimate"
control={form.control}
render={({ field }) => (
<label className="-mt-2 flex items-center gap-2 text-sm">
<Checkbox
checked={field.value}
onCheckedChange={(v) => field.onChange(v === true)}
/>
{t.fields.estimate}
</label>
)}
/>
<Controller
name="accountId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="rule-account">
{type === "transfer" ? tf.fromAccount : tf.account}
</FieldLabel>
<AccountSelect
id="rule-account"
value={field.value}
onChange={field.onChange}
accounts={options.accounts}
invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
{type === "transfer" ? (
<Controller
name="toAccountId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="rule-to">{tf.toAccount}</FieldLabel>
<AccountSelect
id="rule-to"
value={field.value}
onChange={field.onChange}
accounts={options.accounts}
exclude={w.accountId}
invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
) : (
<Controller
name="categoryId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="rule-category">{tf.category}</FieldLabel>
<CategorySelect
id="rule-category"
value={field.value}
onChange={field.onChange}
options={categories}
appearance={options.categoryAppearance}
invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
)}
<Controller
name="payee"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="tx-payee">{tf.payee}</FieldLabel>
<PayeeInput value={field.value} onChange={field.onChange} onPick={() => {}} />
</Field>
)}
/>
<Controller
name="description"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="rule-description">{tf.description}</FieldLabel>
<Input {...field} id="rule-description" />
</Field>
)}
/>
<div className="grid grid-cols-2 gap-3">
<Controller
name="startDate"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="rule-start">{t.fields.startDate}</FieldLabel>
<Input
{...field}
id="rule-start"
type="date"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="frequency"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="rule-frequency">{t.fields.frequency}</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="rule-frequency" className="w-full">
<SelectValue>{t.frequencies[field.value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{FREQUENCIES.map((x) => (
<SelectItem key={x} value={x}>
{t.frequencies[x]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Controller
name="interval"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="rule-interval">
{t.fields.interval} ({t.units[w.frequency ?? "monthly"]})
</FieldLabel>
<Input
{...field}
id="rule-interval"
type="number"
inputMode="numeric"
min={1}
max={12}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
{w.frequency === "monthly" ? (
<Controller
name="monthlyMode"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="rule-monthly">{t.fields.monthlyMode}</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="rule-monthly" className="w-full">
<SelectValue>{t.monthlyModes[field.value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{MONTHLY_MODES.map((x) => (
<SelectItem key={x} value={x}>
{t.monthlyModes[x]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
) : null}
</div>
<div className="grid grid-cols-2 gap-3">
<Controller
name="endKind"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="rule-end">{t.fields.end}</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="rule-end" className="w-full">
<SelectValue>{t.endKinds[field.value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{END_KINDS.map((x) => (
<SelectItem key={x} value={x}>
{t.endKinds[x]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
{w.endKind === "count" ? (
<Controller
name="count"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="rule-count">{t.fields.count}</FieldLabel>
<Input
{...field}
id="rule-count"
type="number"
inputMode="numeric"
min={1}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
) : w.endKind === "date" ? (
<Controller
name="endDate"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="rule-enddate">{t.fields.endDate}</FieldLabel>
<Input
{...field}
id="rule-enddate"
type="date"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
) : null}
</div>
{preview ? (
<FieldDescription>
{preview.text} · {t.fields.preview}:{" "}
{preview.dates.map((d) => formatDate(`${d}T12:00:00+03:00`)).join(", ")}
</FieldDescription>
) : null}
{errors.root ? <FieldError>{errors.root.message}</FieldError> : null}
</FieldGroup>
</form>
)}
</ResponsiveDialog>
);
}
+284
View File
@@ -0,0 +1,284 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { listAccounts } from "@/features/accounts/service";
import {
listTransactions,
updateTransaction,
getTransaction,
} from "@/features/transactions/service";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { RecurringRuleModel } from "@/models/recurring-rule";
import { TransactionModel } from "@/models/transaction";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import { recurringRuleFormSchema, type RecurringRuleFormInput } from "@/shared/schemas/recurring";
import { transactionFilterSchema } from "@/shared/schemas/transaction";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
confirmOccurrence,
createRule,
deleteRule,
ensureHorizon,
getPlannedOverview,
postponeOccurrence,
skipOccurrence,
updateRule,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let emre = "";
let zeynep = "";
let fatma = "";
const today = todayIstanbul();
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
emre = ids.user("emre");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
});
afterAll(teardownDb);
const acc = (name: string) =>
AccountModel.findOne({ householdId: cakir, name })
.lean()
.then((a) => String(a!._id));
const cat = (key: string) =>
CategoryModel.findOne({ householdId: cakir, defaultKey: key })
.lean()
.then((c) => String(c!._id));
async function form(over: Partial<RecurringRuleFormInput> = {}) {
return recurringRuleFormSchema.parse({
type: "expense",
amount: "1.000,00",
accountId: await acc("Ziraat Maaş"),
categoryId: await cat("faturalar.elektrik"),
payee: "Elektrik",
startDate: addDays(today, -40),
frequency: "monthly",
...over,
});
}
const occurrences = (ruleId: string) =>
TransactionModel.find({ recurringRuleId: ruleId }).sort({ occurrenceIndex: 1 }).lean();
async function balance(accountId: string) {
const list = await listAccounts(ayse, cakir);
return list!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
}
describe("kural ve planlı kayıtlar", () => {
let ruleId = "";
it("kural 12 ay ileriye planlı kayıt üretir; planlı kayıtlar bakiyeyi etkilemez", async () => {
const ziraat = await acc("Ziraat Maaş");
const before = await balance(ziraat);
const r = await createRule(ayse, cakir, await form({ isEstimate: true }));
expect(r.ok).toBe(true);
ruleId = r.ok ? r.data.id : "";
const occ = await occurrences(ruleId);
// Başlangıç 40 gün önce: 2 geçmiş + 12 ay ileri (13 ya da 14 kayıt, ay uzunluğuna göre).
expect(occ.length).toBeGreaterThanOrEqual(13);
expect(occ.every((o) => o.status === "planned" && o.isEstimate)).toBe(true);
expect(await balance(ziraat)).toBe(before);
expect(await ensureHorizon((await RecurringRuleModel.findById(ruleId))!.householdId)).toBe(0);
});
it("genel bakış: vadesi geçmiş ve yaklaşan; işlem listesi planlıları varsayılan göstermez", async () => {
const o = (await getPlannedOverview(ayse, cakir))!;
const mine = o.overdue.filter((x) => x.recurringRuleId === ruleId);
expect(mine.length).toBeGreaterThanOrEqual(1);
expect(o.upcoming.every((x) => x.date >= today)).toBe(true);
expect(o.rules.find((x) => x.id === ruleId)).toMatchObject({
recurrence: expect.stringContaining("Her ay"),
remaining: null,
});
const list = await listTransactions(ayse, cakir, transactionFilterSchema.parse({}));
expect(list!.items.some((i) => i.status === "planned")).toBe(false);
const planned = await listTransactions(
ayse,
cakir,
transactionFilterSchema.parse({ durum: "planned" }),
);
expect(planned!.total).toBeGreaterThan(0);
});
it("onayla (tutar/tarih düzeltilerek) bakiyeye yansır; atla iptal eder", async () => {
const ziraat = await acc("Ziraat Maaş");
const before = await balance(ziraat);
const [first, second] = await occurrences(ruleId);
const c = await confirmOccurrence(ayse, cakir, String(first!._id), {
amount: 1_234_56,
date: today,
});
expect(c.ok).toBe(true);
const posted = (await TransactionModel.findById(first!._id).lean())!;
expect(posted).toMatchObject({ status: "posted", amountMinor: 1_234_56, isEstimate: false });
expect(await balance(ziraat)).toBe(before - 1_234_56);
// İkinci kez onaylanamaz.
expect(
(await confirmOccurrence(ayse, cakir, String(first!._id), { amount: 1, date: today })).ok,
).toBe(false);
expect((await skipOccurrence(emre, cakir, String(second!._id))).ok).toBe(true);
expect((await TransactionModel.findById(second!._id).lean())!.status).toBe("void");
expect(await balance(ziraat)).toBe(before - 1_234_56);
});
it("ertele: tarih ileri alınır, kayıt seriden ayrılır; geçmişe ertelenemez", async () => {
const occ = await occurrences(ruleId);
const third = occ[2]!;
const current = third.date.toISOString();
expect((await postponeOccurrence(ayse, cakir, String(third._id), "2000-01-01")).ok).toBe(false);
const target = addDays(today, 200);
expect((await postponeOccurrence(ayse, cakir, String(third._id), target)).ok).toBe(true);
const after = (await TransactionModel.findById(third._id).lean())!;
expect(after.detached).toBe(true);
expect(after.date.toISOString()).not.toBe(current);
});
it("tümünü güncelle: planlılar yeni tutarla yeniden üretilir; gerçekleşen, atlanan ve ayrılan korunur", async () => {
const r = await updateRule(ayse, cakir, ruleId, await form({ amount: "1.500,00" }), "all");
expect(r.ok).toBe(true);
const occ = await occurrences(ruleId);
expect(occ[0]).toMatchObject({ status: "posted", amountMinor: 1_234_56 });
expect(occ[1]).toMatchObject({ status: "void" });
expect(occ[2]).toMatchObject({ detached: true, amountMinor: 1_000_00 });
expect(occ.slice(3).every((o) => o.status === "planned" && o.amountMinor === 1_500_00)).toBe(
true,
);
// Sıra numaraları benzersiz.
expect(new Set(occ.map((o) => o.occurrenceIndex)).size).toBe(occ.length);
});
it("bu ve sonrakiler: seri bölünür", async () => {
const occ = await occurrences(ruleId);
const k = 5;
const from = occ.find((o) => o.occurrenceIndex === k)!;
const r = await updateRule(
ayse,
cakir,
ruleId,
await form({ amount: "2.000,00", startDate: addDays(today, 100) }),
"following",
String(from._id),
);
expect(r.ok).toBe(true);
const newId = r.ok ? r.data.id : "";
expect(newId).not.toBe(ruleId);
expect((await RecurringRuleModel.findById(ruleId).lean())!.count).toBe(k);
const old = await occurrences(ruleId);
expect(old.every((o) => o.occurrenceIndex! < k)).toBe(true);
const fresh = await occurrences(newId);
expect(fresh.length).toBeGreaterThan(0);
expect(fresh.every((o) => o.amountMinor === 2_000_00)).toBe(true);
});
it("tek kaydı düzenlemek (yalnızca bu) kaydı seriden ayırır", async () => {
const r = await createRule(
ayse,
cakir,
await form({ startDate: addDays(today, 3), endKind: "count", count: 3 }),
);
const id = r.ok ? r.data.id : "";
const occ = await occurrences(id);
expect(occ).toHaveLength(3);
const d = (await getTransaction(ayse, cakir, String(occ[1]!._id)))!;
expect(d.status).toBe("planned");
const u = await updateTransaction(ayse, cakir, d.id, {
type: "expense",
amount: 777_00,
date: d.date,
accountId: d.account.id,
toAccountId: null,
categoryId: d.categoryId,
splits: [],
payee: d.payeeName,
description: "",
tags: [],
attachmentIds: [],
installments: 1,
});
expect(u.ok).toBe(true);
expect(await TransactionModel.findById(d.id).lean()).toMatchObject({
detached: true,
status: "planned",
amountMinor: 777_00,
});
// Silme: bu ve sonrakiler (index 1'den) → yalnızca ilk kalır, kural 1 tekrarla biter.
expect((await deleteRule(ayse, cakir, id, "following", d.id)).ok).toBe(true);
expect(await occurrences(id)).toHaveLength(1);
expect((await deleteRule(ayse, cakir, id, "all")).ok).toBe(true);
expect(await occurrences(id)).toHaveLength(0);
expect((await RecurringRuleModel.findById(id).lean())!.deletedAt).toBeInstanceOf(Date);
});
});
describe("yetki ve izolasyon", () => {
it("izleyici kural oluşturamaz/onaylayamaz; hane dışı göremez", async () => {
expect((await createRule(zeynep, cakir, await form())).ok).toBe(false);
const o = (await getPlannedOverview(zeynep, cakir))!;
const any = o.upcoming[0]!;
expect((await confirmOccurrence(zeynep, cakir, any.id, { amount: 1, date: today })).ok).toBe(
false,
);
expect(await getPlannedOverview(fatma, cakir)).toBeNull();
expect((await skipOccurrence(fatma, cakir, any.id)).ok).toBe(false);
});
it("gizli hesap kuralı ve kayıtları yalnızca sahibine", async () => {
const pluxee = await acc("Ayşe — Pluxee");
expect(
(
await createRule(
emre,
cakir,
await form({ accountId: pluxee, startDate: addDays(today, 1) }),
)
).ok,
).toBe(false);
const r = await createRule(
ayse,
cakir,
await form({ accountId: pluxee, startDate: addDays(today, 1), payee: "Gizli kural" }),
);
const id = r.ok ? r.data.id : "";
const mine = (await getPlannedOverview(ayse, cakir))!;
expect(mine.rules.some((x) => x.id === id)).toBe(true);
const theirs = (await getPlannedOverview(emre, cakir))!;
expect(theirs.rules.some((x) => x.id === id)).toBe(false);
expect(theirs.upcoming.some((x) => x.recurringRuleId === id)).toBe(false);
expect((await deleteRule(emre, cakir, id, "all")).ok).toBe(false);
});
it("form doğrulaması: bitiş tarihi başlangıçtan önce olamaz; transferde hedef hesap", () => {
expect(
recurringRuleFormSchema.safeParse({
type: "expense",
amount: "10",
accountId: "a".repeat(24),
categoryId: "b".repeat(24),
startDate: "2026-05-01",
frequency: "monthly",
endKind: "date",
endDate: "2026-04-01",
}).success,
).toBe(false);
expect(
recurringRuleFormSchema.safeParse({
type: "transfer",
amount: "10",
accountId: "a".repeat(24),
startDate: "2026-05-01",
frequency: "monthly",
}).success,
).toBe(false);
});
});
+608
View File
@@ -0,0 +1,608 @@
import "server-only";
import mongoose, { Types, type ClientSession } from "mongoose";
import {
baseFilter,
buildDoc,
loadContext,
loadOwnTransaction,
toItem,
touchPayee,
userNames,
type Ctx,
type LeanTx,
type TransactionListItem,
} from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { writeAudit } from "@/lib/audit";
import { toObjectId } from "@/lib/object-id";
import { RecurringRuleModel } from "@/models/recurring-rule";
import { TransactionModel } from "@/models/transaction";
import {
addDays,
describeRecurrence,
horizonDate,
listOccurrences,
type Recurrence,
} from "@/shared/recurrence";
import type { ConfirmOccurrenceForm, RecurringRuleForm } from "@/shared/schemas/recurring";
import type { TransactionForm } from "@/shared/schemas/transaction";
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
import { foldTr } from "@/shared/search";
import type { TransactionType } from "@/shared/transactions";
/**
* Planlı ve düzenli işlemler (Bölüm 6.7). Okuma: viewer; yazma: member.
* Planlı kayıtlar bakiyeyi etkilemez; "Onayla" ile posted olur. Gizli hesap kuralları ve
* kayıtları işlemlerle aynı kuralla yalnızca hesap sahibine görünür.
*/
const t = tr.recurring;
/** "Yaklaşan" listesinin kapsadığı gün sayısı. */
export const UPCOMING_DAYS = 60;
type RuleDoc = {
_id: Types.ObjectId;
householdId: Types.ObjectId;
type: TransactionType;
amountMinor: number;
isEstimate: boolean;
accountId: Types.ObjectId;
toAccountId?: Types.ObjectId | null;
categoryId?: Types.ObjectId | null;
payeeName: string;
description: string;
tags: string[];
frequency: Recurrence["frequency"];
interval: number;
monthlyMode: Recurrence["monthlyMode"];
startDate: string;
endDate?: string | null;
count?: number | null;
generatedUntil?: string | null;
createdBy: Types.ObjectId;
updatedBy: Types.ObjectId;
};
const toRecurrence = (r: RuleDoc): Recurrence => ({
frequency: r.frequency,
interval: r.interval,
monthlyMode: r.monthlyMode,
startDate: r.startDate,
endDate: r.endDate ?? null,
count: r.count ?? null,
});
function asTransactionForm(input: RecurringRuleForm): TransactionForm {
return {
type: input.type,
amount: input.amount,
date: input.startDate,
accountId: input.accountId,
toAccountId: input.toAccountId,
categoryId: input.type === "transfer" ? null : input.categoryId,
splits: [],
payee: input.payee,
description: input.description,
tags: input.tags,
attachmentIds: [],
installments: 1,
};
}
/** Form → kural alanları (doğrulanmış işlem şablonu + tekrar). */
function ruleFields(ctx: Ctx, input: RecurringRuleForm, existing?: RuleDoc) {
const built = buildDoc(ctx, asTransactionForm(input), existing);
if (!built.ok) return built;
const d = built.data as {
type: TransactionType;
accountId: Types.ObjectId;
toAccountId: Types.ObjectId | null;
categoryId: Types.ObjectId | null;
amountMinor: number;
payeeName: string;
description: string;
tags: string[];
};
return ok({
type: d.type,
amountMinor: d.amountMinor,
isEstimate: input.isEstimate,
accountId: d.accountId,
toAccountId: d.toAccountId,
categoryId: d.categoryId,
payeeName: d.payeeName,
description: d.description,
tags: d.tags,
frequency: input.frequency,
interval: input.interval,
monthlyMode: input.monthlyMode,
startDate: input.startDate,
endDate: input.endDate,
count: input.count,
});
}
/** Kuralın ufka kadar eksik planlı kayıtlarını üretir (mevcut sıra numaraları atlanır). */
async function generate(rule: RuleDoc, today: string, session?: ClientSession): Promise<number> {
const until = horizonDate(today);
const occurrences = listOccurrences(toRecurrence(rule), until);
const existing = new Set(
(
await TransactionModel.find(
{ recurringRuleId: rule._id },
{ occurrenceIndex: 1 },
{ session },
).lean()
).map((x) => x.occurrenceIndex),
);
const missing = occurrences.filter((o) => !existing.has(o.index));
if (missing.length) {
const docs = missing.map((o) => ({
householdId: rule.householdId,
type: rule.type,
status: "planned",
date: istanbulDayStart(o.date)!,
accountId: rule.accountId,
toAccountId: rule.toAccountId ?? null,
amountMinor: rule.amountMinor,
categoryId: rule.categoryId ?? null,
splits: [],
effectiveCategoryIds: rule.categoryId ? [rule.categoryId] : [],
payeeName: rule.payeeName,
description: rule.description,
searchText: foldTr(`${rule.payeeName} ${rule.description}`),
tags: rule.tags,
isEstimate: rule.isEstimate,
recurringRuleId: rule._id,
occurrenceIndex: o.index,
createdBy: rule.createdBy,
updatedBy: rule.updatedBy,
}));
try {
await TransactionModel.insertMany(docs, { session, ordered: false });
} catch (error) {
// Eşzamanlı üretim: benzersiz index kopyayı reddeder (transaction dışında).
if (session || (error as { code?: number }).code !== 11000) throw error;
}
}
await RecurringRuleModel.updateOne(
{ _id: rule._id },
{ $set: { generatedUntil: until } },
{ session },
);
return missing.length;
}
/** Hanedeki kuralların ufkunu ilerletir (okuma sırasında; zamanlanmış görev gerekmez). */
export async function ensureHorizon(householdId: Types.ObjectId, today = todayIstanbul()) {
const until = horizonDate(today);
const rules = await RecurringRuleModel.find({
householdId,
deletedAt: null,
$or: [{ generatedUntil: null }, { generatedUntil: { $lt: until } }],
}).lean<RuleDoc[]>();
let created = 0;
for (const rule of rules) created += await generate(rule, today);
return created;
}
function ruleVisible(ctx: Ctx, rule: Pick<RuleDoc, "accountId" | "toAccountId">) {
return [rule.accountId, rule.toAccountId]
.filter(Boolean)
.every((id) => !ctx.accountMap.get(String(id))?.hidden);
}
async function loadRule(ctx: Ctx, ruleId: string, session?: ClientSession) {
const id = toObjectId(ruleId);
if (!id) return null;
const rule = await RecurringRuleModel.findOne(
{ _id: id, householdId: ctx.m.householdId, deletedAt: null },
null,
{ session },
).lean<RuleDoc>();
return rule && ruleVisible(ctx, rule) ? rule : null;
}
/** "Bu ve sonrakiler" için başlangıç tekrarı (kurala ait olmalı). */
async function occurrenceIndexOf(rule: RuleDoc, transactionId: string | undefined) {
const id = transactionId ? toObjectId(transactionId) : null;
if (!id) return null;
const tx = await TransactionModel.findOne(
{ _id: id, recurringRuleId: rule._id, householdId: rule.householdId },
{ occurrenceIndex: 1 },
).lean();
return tx?.occurrenceIndex ?? null;
}
// --- Kural yazma ---
export async function createRule(
actorId: string,
householdId: string,
input: RecurringRuleForm,
): Promise<ActionResult<{ id: string; generated: number }>> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const fields = ruleFields(ctx, input);
if (!fields.ok) return fields;
const actor = new Types.ObjectId(actorId);
let out = { id: "", generated: 0 };
await mongoose.connection.transaction(async (session) => {
const [rule] = await RecurringRuleModel.create(
[{ ...fields.data, householdId: ctx.m.householdId, createdBy: actor, updatedBy: actor }],
{ session },
);
const generated = await generate(rule!.toObject() as RuleDoc, todayIstanbul(), session);
out = { id: String(rule!._id), generated };
});
return ok(out);
}
/**
* Seri düzenleme. "all": kural güncellenir, seriden ayrılmamış planlı kayıtlar yeniden üretilir
* (gerçekleşmiş/atlanmış kayıtlar korunur). "following": seri bu tekrardan bölünür — eski kural
* önceki tekrarda biter, yeni kural bu tekrardan itibaren yeni değerlerle başlar.
*/
export async function updateRule(
actorId: string,
householdId: string,
ruleId: string,
input: RecurringRuleForm,
scope: "all" | "following",
fromTransactionId?: string,
): Promise<ActionResult<{ id: string }>> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const rule = await loadRule(ctx, ruleId);
if (!rule) return fail(tr.common.notFound);
const fields = ruleFields(ctx, input, rule);
if (!fields.ok) return fields;
const actor = new Types.ObjectId(actorId);
const from = scope === "following" ? await occurrenceIndexOf(rule, fromTransactionId) : 0;
if (from === null) return fail(tr.common.notFound);
const today = todayIstanbul();
let id = String(rule._id);
await mongoose.connection.transaction(async (session) => {
if (from === 0) {
await RecurringRuleModel.updateOne(
{ _id: rule._id },
{ $set: { ...fields.data, generatedUntil: null, updatedBy: actor } },
{ session },
);
await TransactionModel.deleteMany(
{
recurringRuleId: rule._id,
status: "planned",
...(scope === "all" ? { detached: false } : {}),
},
{ session },
);
const fresh = await RecurringRuleModel.findById(rule._id, null, { session }).lean<RuleDoc>();
await generate(fresh!, today, session);
return;
}
// Böl: eski kural `from` tekrarından önce biter.
await RecurringRuleModel.updateOne(
{ _id: rule._id },
{ $set: { count: from, updatedBy: actor } },
{ session },
);
await TransactionModel.deleteMany(
{ recurringRuleId: rule._id, status: "planned", occurrenceIndex: { $gte: from } },
{ session },
);
const [next] = await RecurringRuleModel.create(
[{ ...fields.data, householdId: ctx.m.householdId, createdBy: actor, updatedBy: actor }],
{ session },
);
id = String(next!._id);
await generate(next!.toObject() as RuleDoc, today, session);
});
return ok({ id });
}
export async function deleteRule(
actorId: string,
householdId: string,
ruleId: string,
scope: "all" | "following",
fromTransactionId?: string,
): Promise<ActionResult<{ removed: number }>> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const rule = await loadRule(ctx, ruleId);
if (!rule) return fail(tr.common.notFound);
const from = scope === "following" ? await occurrenceIndexOf(rule, fromTransactionId) : 0;
if (from === null) return fail(tr.common.notFound);
const actor = new Types.ObjectId(actorId);
let removed = 0;
await mongoose.connection.transaction(async (session) => {
const res = await TransactionModel.deleteMany(
{ recurringRuleId: rule._id, status: "planned", occurrenceIndex: { $gte: from } },
{ session },
);
removed = res.deletedCount;
await RecurringRuleModel.updateOne(
{ _id: rule._id },
from === 0
? { $set: { deletedAt: new Date(), updatedBy: actor } }
: { $set: { count: from, updatedBy: actor } },
{ session },
);
await writeAudit(
{
action: "recurring.delete",
actorId: actor,
householdId: ctx.m.householdId,
targetType: "recurringRule",
targetId: String(rule._id),
diff: { scope, fromIndex: from, removedPlanned: removed },
},
session,
);
});
return ok({ removed });
}
// --- Planlı kayıt işlemleri ---
async function loadPlanned(ctx: Ctx, transactionId: string) {
const tx = await loadOwnTransaction(ctx, transactionId);
return tx && tx.status === "planned" ? tx : null;
}
/** Onayla: planlı → gerçekleşti; tutar ve tarih düzeltilebilir. */
export async function confirmOccurrence(
actorId: string,
householdId: string,
transactionId: string,
input: ConfirmOccurrenceForm,
): Promise<ActionResult> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const tx = await loadPlanned(ctx, transactionId);
if (!tx) return fail(tr.common.notFound);
const date = istanbulDayStart(input.date);
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
if (tx.splits?.length && input.amount !== tx.amountMinor) return fail(t.errors.splitAmount);
// Arşivlenmiş/kapalı hesap veya kategoriye bağlı kayıt onaylanamaz.
for (const id of [tx.accountId, tx.toAccountId].filter(Boolean)) {
const a = ctx.accountMap.get(String(id));
if (!a || a.archived) return fail(tr.transactions.validation.accountInvalid);
}
const payeeId = await touchPayee(
ctx.m.householdId,
tx.payeeName ?? "",
tx.type,
tx.accountId,
tx.categoryId ?? null,
);
await TransactionModel.updateOne(
{ _id: tx._id, householdId: ctx.m.householdId, status: "planned" },
{
$set: {
status: "posted",
amountMinor: input.amount,
date,
isEstimate: false,
payeeId,
updatedBy: new Types.ObjectId(actorId),
},
},
);
return ok(undefined);
}
/** Atla: bu tekrar gerçekleşmeyecek (iptal); seri devam eder. */
export async function skipOccurrence(
actorId: string,
householdId: string,
transactionId: string,
): Promise<ActionResult> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const tx = await loadPlanned(ctx, transactionId);
if (!tx) return fail(tr.common.notFound);
await TransactionModel.updateOne(
{ _id: tx._id, householdId: ctx.m.householdId, status: "planned" },
{ $set: { status: "void", updatedBy: new Types.ObjectId(actorId) } },
);
return ok(undefined);
}
/** Ertele: tarihi ileri al (seri tarihleri değişmez; kayıt seriden ayrılır). */
export async function postponeOccurrence(
actorId: string,
householdId: string,
transactionId: string,
ymd: string,
): Promise<ActionResult> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const tx = await loadPlanned(ctx, transactionId);
if (!tx) return fail(tr.common.notFound);
const date = istanbulDayStart(ymd);
if (!date || ymd <= toIstanbulYmd(tx.date))
return fail(t.errors.postponeDate, { date: t.errors.postponeDate });
await TransactionModel.updateOne(
{ _id: tx._id, householdId: ctx.m.householdId, status: "planned" },
{ $set: { date, detached: true, updatedBy: new Types.ObjectId(actorId) } },
);
return ok(undefined);
}
// --- Okuma ---
export type PlannedItem = TransactionListItem & {
recurringRuleId: string | null;
isEstimate: boolean;
/** "3/12" (tekrar sayılı seride). */
seriesPosition: string | null;
};
export type RuleView = {
id: string;
type: TransactionType;
title: string;
amountMinor: number;
isEstimate: boolean;
recurrence: string;
account: string;
toAccount: string | null;
category: string | null;
nextDate: string | null;
/** Kalan planlı kayıt (tekrar sayılı seride kalan tekrar). */
remaining: number | null;
startDate: string;
endDate: string | null;
count: number | null;
};
export type PlannedOverview = {
overdue: PlannedItem[];
upcoming: PlannedItem[];
rules: RuleView[];
};
async function plannedItems(ctx: Ctx, filter: Record<string, unknown>, limit = 500) {
const rows = (await TransactionModel.find({ ...baseFilter(ctx), status: "planned", ...filter })
.sort({ date: 1, createdAt: 1 })
.limit(limit)
.lean()) as unknown as LeanTx[];
const ruleIds = [
...new Set(
rows
.map((r) => r.recurringRuleId)
.filter(Boolean)
.map(String),
),
];
const rules = await RecurringRuleModel.find({ _id: { $in: ruleIds } }, { count: 1 }).lean();
const counts = new Map(rules.map((r) => [String(r._id), r.count ?? null]));
const names = await userNames(rows.map((r) => r.createdBy as Types.ObjectId));
return rows.map((r): PlannedItem => {
const ruleId = r.recurringRuleId ? String(r.recurringRuleId) : null;
const count = ruleId ? counts.get(ruleId) : null;
return {
...toItem(ctx, r, names),
recurringRuleId: ruleId,
isEstimate: Boolean(r.isEstimate),
seriesPosition:
count && r.occurrenceIndex !== null && r.occurrenceIndex !== undefined
? `${r.occurrenceIndex + 1}/${count}`
: null,
};
});
}
export async function getPlannedOverview(
userId: string,
householdId: string,
): Promise<PlannedOverview | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
await ensureHorizon(ctx.m.householdId);
const today = todayIstanbul();
const todayStart = istanbulDayStart(today)!;
const end = istanbulDayStart(addDays(today, UPCOMING_DAYS + 1))!;
const [overdue, upcoming, rules] = await Promise.all([
plannedItems(ctx, { date: { $lt: todayStart } }),
plannedItems(ctx, { date: { $gte: todayStart, $lt: end } }),
listRules(ctx),
]);
return { overdue, upcoming, rules };
}
async function listRules(ctx: Ctx): Promise<RuleView[]> {
const rules = (
await RecurringRuleModel.find({ householdId: ctx.m.householdId, deletedAt: null })
.sort({ createdAt: 1 })
.lean<RuleDoc[]>()
).filter((r) => ruleVisible(ctx, r));
const next = await TransactionModel.aggregate<{
_id: Types.ObjectId;
date: Date | null;
planned: number;
done: number;
}>([
{ $match: { recurringRuleId: { $in: rules.map((r) => r._id) }, deletedAt: null } },
{
$group: {
_id: "$recurringRuleId",
date: { $min: { $cond: [{ $eq: ["$status", "planned"] }, "$date", null] } },
planned: { $sum: { $cond: [{ $eq: ["$status", "planned"] }, 1, 0] } },
done: { $sum: { $cond: [{ $eq: ["$status", "planned"] }, 0, 1] } },
},
},
]);
const nextMap = new Map(next.map((x) => [String(x._id), x]));
const name = (id?: Types.ObjectId | null) =>
id ? (ctx.accountMap.get(String(id))?.name ?? "—") : null;
return rules
.map((r): RuleView => {
const n = nextMap.get(String(r._id));
const category = r.categoryId ? ctx.categoryMap.get(String(r.categoryId)) : undefined;
return {
id: String(r._id),
type: r.type,
title: r.payeeName || r.description || category?.name || tr.transactions.types[r.type],
amountMinor: r.amountMinor,
isEstimate: r.isEstimate,
recurrence: describeRecurrence(r),
account: name(r.accountId)!,
toAccount: name(r.toAccountId),
category: category?.path ?? null,
nextDate: n?.date ? toIstanbulYmd(n.date) : null,
// Tekrar sayılı seride: toplam − gerçekleşen/atlanan; bitiş tarihlide: kalan planlı kayıt.
remaining: r.count
? Math.max(0, r.count - (n?.done ?? 0))
: r.endDate
? (n?.planned ?? 0)
: null,
startDate: r.startDate,
endDate: r.endDate ?? null,
count: r.count ?? null,
};
})
.filter((r) => r.nextDate !== null || r.remaining === null);
}
/** Düzenleme formu için kural değerleri. */
export async function getRuleForm(userId: string, householdId: string, ruleId: string) {
const ctx = await loadContext(userId, householdId, "member");
if (!ctx) return null;
const r = await loadRule(ctx, ruleId);
if (!r) return null;
return {
id: String(r._id),
type: r.type,
amountMinor: r.amountMinor,
isEstimate: r.isEstimate,
accountId: String(r.accountId),
toAccountId: r.toAccountId ? String(r.toAccountId) : null,
categoryId: r.categoryId ? String(r.categoryId) : null,
payee: r.payeeName,
description: r.description,
tags: r.tags,
startDate: r.startDate,
frequency: r.frequency,
interval: r.interval,
monthlyMode: r.monthlyMode,
endDate: r.endDate ?? null,
count: r.count ?? null,
};
}
export type RuleFormValues = NonNullable<Awaited<ReturnType<typeof getRuleForm>>>;
/** Dashboard uyarısı: vadesi geçmiş onaylanmamış planlı kayıt sayısı. */
export async function countOverdue(userId: string, householdId: string): Promise<number> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return 0;
return TransactionModel.countDocuments({
...baseFilter(ctx),
status: "planned",
date: { $lt: istanbulDayStart(todayIstanbul())! },
});
}
+45
View File
@@ -5,8 +5,10 @@ import { z } from "zod";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result"; import { fail, ok, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal"; import { getHouseholdActionContext } from "@/lib/dal";
import { reconcileSchema } from "@/shared/schemas/reconciliation";
import { transactionFormSchema } from "@/shared/schemas/transaction"; import { transactionFormSchema } from "@/shared/schemas/transaction";
import { import {
cancelRemainingInstallments,
createTransaction, createTransaction,
getTransaction, getTransaction,
getTransactionFormOptions, getTransactionFormOptions,
@@ -17,6 +19,7 @@ import {
type TransactionDetail, type TransactionDetail,
type TransactionFormOptions, type TransactionFormOptions,
} from "./service"; } from "./service";
import { previewReconciliation, reconcileAccount, type ReconcilePreview } from "./statement";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
@@ -103,3 +106,45 @@ export async function searchPayeesAction(query: unknown): Promise<PayeeSuggestio
if (!ctx || !q.success) return []; if (!ctx || !q.success) return [];
return searchPayees(ctx.user.id, ctx.household.id, q.data); return searchPayees(ctx.user.id, ctx.household.id, q.data);
} }
/** Taksitli alışverişin kalan (gerçekleşmemiş) taksitlerini iptal eder. */
export async function cancelRemainingInstallmentsAction(
transactionId: unknown,
): Promise<ActionResult<{ cancelled: number }>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(transactionId);
if (!id.success) return fail(tr.common.notFound);
const result = await cancelRemainingInstallments(ctx.user.id, ctx.household.id, id.data);
if (result.ok) revalidate();
return result;
}
/** Mutabakat önizlemesi: ekstre bakiyesi ile uygulama bakiyesi arasındaki fark. */
export async function previewReconciliationAction(
accountId: unknown,
input: unknown,
): Promise<ActionResult<ReconcilePreview>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(accountId);
if (!id.success) return fail(tr.common.notFound);
const parsed = reconcileSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
return previewReconciliation(ctx.user.id, ctx.household.id, id.data, parsed.data);
}
export async function reconcileAccountAction(
accountId: unknown,
input: unknown,
): Promise<ActionResult<{ adjustmentId: string | null; markedCount: number }>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(accountId);
if (!id.success) return fail(tr.common.notFound);
const parsed = reconcileSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await reconcileAccount(ctx.user.id, ctx.household.id, id.data, parsed.data);
if (result.ok) revalidate();
return result;
}
+297
View File
@@ -0,0 +1,297 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createAccount, listAccounts } from "@/features/accounts/service";
import { AccountModel } from "@/models/account";
import { CardStatementModel } from "@/models/card-statement";
import { CategoryModel } from "@/models/category";
import { TransactionModel } from "@/models/transaction";
import { cardPeriods, minimumPayment } from "@/shared/card-periods";
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import { transactionFormSchema, type TransactionForm } from "@/shared/schemas/transaction";
import { transactionEffects } from "@/shared/transactions";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import { getCardSummary, getOverdraftSummary } from "./card";
import {
cancelRemainingInstallments,
createTransaction,
listTransactions,
postDueInstallments,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let fatma = "";
const today = todayIstanbul();
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
fatma = ids.user("fatma");
});
afterAll(teardownDb);
const acc = (name: string) =>
AccountModel.findOne({ householdId: cakir, name })
.lean()
.then((a) => String(a!._id));
const cat = (key: string) =>
CategoryModel.findOne({ householdId: cakir, defaultKey: key })
.lean()
.then((c) => String(c!._id));
async function form(over: Partial<TransactionForm>): Promise<TransactionForm> {
return {
type: "expense",
amount: 100_00,
date: today,
accountId: await acc("Garanti Bonus"),
toAccountId: null,
categoryId: await cat("giyim"),
splits: [],
payee: "",
description: "",
tags: [],
installments: 1,
attachmentIds: [],
...over,
};
}
/** Bağımsız hesap: açılış + belirli andan önceki gerçekleşmiş işlemler. */
async function balanceAt(accountId: string, before?: Date) {
const account = (await AccountModel.findById(accountId).lean())!;
const txs = await TransactionModel.find({
householdId: cakir,
deletedAt: null,
...(before ? { date: { $lt: before } } : {}),
}).lean();
let b = account.openingBalanceMinor;
for (const t of txs) {
for (const e of transactionEffects({
type: t.type,
status: t.status,
amountMinor: t.amountMinor,
accountId: String(t.accountId),
toAccountId: t.toAccountId ? String(t.toAccountId) : null,
}))
if (e.accountId === accountId) b += e.deltaMinor;
}
return b;
}
describe("kart özeti", () => {
it("anlık borç, ekstre borcu (kesim günü dahil) ve asgari ödeme işlemlerle tutarlı", async () => {
const card = await acc("Garanti Bonus");
const s = (await getCardSummary(ayse, cakir, card))!;
const { statement, current } = cardPeriods(today, 29);
expect(s.statement.period).toEqual(statement);
expect(s.current.start).toBe(current.start);
expect(s.debtMinor).toBe(Math.max(0, -(await balanceAt(card))));
const endEx = new Date(istanbulDayStart(statement.end)!.getTime() + 24 * 3600 * 1000);
expect(s.statement.debtMinor).toBe(Math.max(0, -(await balanceAt(card, endEx))));
// Limit 60.000 TL ≥ 50.000 → %40.
expect(s.minimumRateBp).toBe(4000);
expect(s.statement.minimumMinor).toBe(minimumPayment(s.statement.debtMinor, 4000));
expect(s.history).toHaveLength(6);
expect(s.paymentAccount?.name).toBe("Ziraat Maaş");
expect(await CardStatementModel.countDocuments({ accountId: card })).toBe(6);
});
it("karta ödeme ekstre borcunu düşürür", async () => {
const card = await acc("Garanti Bonus");
const before = (await getCardSummary(ayse, cakir, card))!;
const r = await createTransaction(
ayse,
cakir,
await form({
type: "transfer",
amount: 1_000_00,
accountId: await acc("Ziraat Maaş"),
toAccountId: card,
categoryId: null,
}),
);
expect(r.ok).toBe(true);
const after = (await getCardSummary(ayse, cakir, card))!;
expect(after.statement.paidMinor).toBe(before.statement.paidMinor + 1_000_00);
expect(after.debtMinor).toBe(before.debtMinor - 1_000_00);
});
it("yetki: hane dışı ve kart olmayan hesap", async () => {
expect(await getCardSummary(fatma, cakir, await acc("Garanti Bonus"))).toBeNull();
expect(await getCardSummary(ayse, cakir, await acc("Cüzdan"))).toBeNull();
});
});
describe("taksitli harcama", () => {
let first = "";
it("her taksit ayrı işlem; vadesi gelenler gerçekleşir, gelecekler planlı ve limitten düşer", async () => {
const card = await acc("Garanti Bonus");
const balanceBefore = await balanceAt(card);
const summaryBefore = (await getCardSummary(ayse, cakir, card))!;
const date = addDays(today, -65);
const r = await createTransaction(
ayse,
cakir,
await form({ amount: 9_000_00, installments: 9, payee: "Hepsiburada", date }),
);
expect(r.ok).toBe(true);
first = r.ok ? r.data.id : "";
const plan = (await TransactionModel.findById(first).lean())!.installment!.planId;
const all = await TransactionModel.find({ "installment.planId": plan })
.sort({ "installment.index": 1 })
.lean();
expect(all).toHaveLength(9);
expect(all.map((t) => t.installment!.index)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
expect(all.reduce((a, t) => a + t.amountMinor, 0)).toBe(9_000_00);
const posted = all.filter((t) => t.status === "posted");
expect(posted.length).toBeGreaterThanOrEqual(3);
expect(all.filter((t) => t.status === "planned").every((t) => t.date > new Date())).toBe(true);
expect(await balanceAt(card)).toBe(
balanceBefore - posted.reduce((a, t) => a + t.amountMinor, 0),
);
const s = (await getCardSummary(ayse, cakir, card))!;
const pending = all
.filter((t) => t.status === "planned")
.reduce((a, t) => a + t.amountMinor, 0);
expect(s.pendingInstallmentsMinor).toBe(summaryBefore.pendingInstallmentsMinor + pending);
expect(s.availableMinor).toBe(s.limitMinor - s.debtMinor - s.pendingInstallmentsMinor);
expect(s.upcomingInstallments.reduce((a, x) => a + x.totalMinor, 0)).toBe(
s.pendingInstallmentsMinor,
);
const list = await listTransactions(
ayse,
cakir,
(await import("@/shared/schemas/transaction")).transactionFilterSchema.parse({
q: "Hepsiburada",
}),
);
expect(list!.items[0]!.installment).toMatchObject({ count: 9 });
});
it("vadesi gelen taksit kendi döneminde otomatik gerçekleşir", async () => {
const plan = (await TransactionModel.findById(first).lean())!.installment!.planId;
const plannedBefore = await TransactionModel.countDocuments({
"installment.planId": plan,
status: "planned",
});
await postDueInstallments(
(await AccountModel.findById(await acc("Garanti Bonus")).lean())!.householdId,
addDays(today, 40),
);
const plannedAfter = await TransactionModel.countDocuments({
"installment.planId": plan,
status: "planned",
});
expect(plannedAfter).toBe(plannedBefore - 1);
});
it("kalan taksitler iptal edilebilir; gerçekleşenler korunur", async () => {
const plan = (await TransactionModel.findById(first).lean())!.installment!.planId;
const posted = await TransactionModel.countDocuments({
"installment.planId": plan,
status: "posted",
});
const r = await cancelRemainingInstallments(ayse, cakir, first);
expect(r).toMatchObject({ ok: true });
expect(
await TransactionModel.countDocuments({
"installment.planId": plan,
status: "planned",
deletedAt: null,
}),
).toBe(0);
expect(
await TransactionModel.countDocuments({
"installment.planId": plan,
status: "posted",
deletedAt: null,
}),
).toBe(posted);
});
it("taksit yalnızca kredi kartı giderinde", async () => {
const onBank = await createTransaction(
ayse,
cakir,
await form({ installments: 3, accountId: await acc("Ziraat Maaş") }),
);
expect(onBank.ok).toBe(false);
const base = {
type: "income",
amount: "100",
date: today,
accountId: "a".repeat(24),
categoryId: "b".repeat(24),
installments: "3",
};
expect(transactionFormSchema.safeParse(base).success).toBe(false);
expect(transactionFormSchema.safeParse({ ...base, type: "expense" }).success).toBe(true);
expect(
transactionFormSchema.safeParse({ ...base, type: "expense", installments: "37" }).success,
).toBe(false);
expect(
transactionFormSchema.parse({ ...base, type: "expense", installments: "" }).installments,
).toBe(1);
});
});
describe("KMH özeti", () => {
it("dönem içi eksi bakiyeden faiz tahmini; kullanılabilir KMH", async () => {
const created = await createAccount(ayse, cakir, {
name: "KMH Test",
type: "bank",
institution: "",
mealCardProvider: null,
openingBalance: 0,
openingDate: "2020-01-01",
color: "blue",
icon: "landmark",
ownerUserId: null,
isPrivate: false,
overdraftEnabled: true,
overdraftLimit: 10_000_00,
overdraftMonthlyRate: 425,
overdraftStatementDay: 1,
cardLimit: 0,
cardStatementDay: 1,
cardDueKind: "daysAfter",
cardDueValue: 10,
cardPaymentAccountId: null,
cardMinimumRate: null,
});
expect(created.ok).toBe(true);
const id = created.ok ? created.data.id : "";
const { statement } = cardPeriods(today, 1);
await createTransaction(
ayse,
cakir,
await form({ accountId: id, amount: 3_000_00, date: statement.start }),
);
const s = (await getOverdraftSummary(ayse, cakir, id))!;
const days =
Math.round(
(istanbulDayStart(statement.end)!.getTime() -
istanbulDayStart(statement.start)!.getTime()) /
(24 * 3600 * 1000),
) + 1;
expect(s.statement.interestMinor).toBe(Math.round((days * 3_000_00 * 425) / 10_000 / 30));
expect(s.usedMinor).toBe(3_000_00);
expect(s.availableMinor).toBe(7_000_00);
// Artı bakiyeli hesapta kullanılabilir KMH limitin kendisi.
const z = (await getOverdraftSummary(ayse, cakir, await acc("Ziraat Maaş")))!;
expect(z.usedMinor).toBe(0);
expect(z.availableMinor).toBe(z.limitMinor);
expect(s.interestCategoryId).not.toBeNull();
expect(s.interestBooked).toBe(false);
expect((await listAccounts(ayse, cakir))!.accounts.find((a) => a.id === id)!.balanceMinor).toBe(
-3_000_00,
);
expect(await getOverdraftSummary(ayse, cakir, await acc("Cüzdan"))).toBeNull();
});
});
+396
View File
@@ -0,0 +1,396 @@
import "server-only";
import { Types, type PipelineStage } from "mongoose";
import { AccountModel } from "@/models/account";
import { CardStatementModel } from "@/models/card-statement";
import { CategoryModel } from "@/models/category";
import { TransactionModel } from "@/models/transaction";
import {
cardPeriods,
closingOnOrAfter,
dueDate,
estimateOverdraftInterest,
minimumPayment,
minimumRateBp,
nextPeriod,
previousPeriod,
type DueRule,
type Period,
} from "@/shared/card-periods";
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import { loadContext, toItem, userNames, type LeanTx, type TransactionListItem } from "./service";
import { accountMatch, balanceBefore } from "./statement";
/**
* Kredi kartı özeti ve KMH dönem özeti (Bölüm 6.8). Tüm tutarlar işlemlerden hesaplanır;
* dönem kayıtları (`statements`) okunurken güncellenir.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
const HISTORY = 6;
const dayStart = (ymd: string) => istanbulDayStart(ymd)!;
const endExclusive = (ymd: string) => new Date(dayStart(ymd).getTime() + DAY_MS);
type AccountDoc = {
_id: Types.ObjectId;
householdId: Types.ObjectId;
name: string;
type: string;
openingBalanceMinor: number;
archivedAt?: Date | null;
creditCard?: {
limitMinor: number;
statementDay: number;
dueRule: DueRule;
paymentAccountId?: Types.ObjectId | null;
minimumPaymentRateBp?: number | null;
} | null;
overdraft?: {
enabled: boolean;
limitMinor: number;
monthlyRateBp?: number | null;
statementDay?: number | null;
} | null;
};
/** Hesaba giriş (gelir, gelen transfer) ve çıkış (gider, giden transfer) toplamları. */
async function flows(householdId: Types.ObjectId, accountId: Types.ObjectId, from: Date, to: Date) {
const pipeline: PipelineStage[] = [
{ $match: { ...accountMatch(householdId, accountId), date: { $gte: from, $lt: to } } },
{
$group: {
_id: null,
inflow: {
$sum: {
$cond: [
{
$or: [
{ $and: [{ $eq: ["$type", "income"] }, { $eq: ["$accountId", accountId] }] },
{ $and: [{ $eq: ["$type", "transfer"] }, { $eq: ["$toAccountId", accountId] }] },
],
},
"$amountMinor",
0,
],
},
},
outflow: {
$sum: {
$cond: [
{
$and: [
{ $in: ["$type", ["expense", "transfer"]] },
{ $eq: ["$accountId", accountId] },
],
},
"$amountMinor",
0,
],
},
},
},
},
];
const [row] = await TransactionModel.aggregate<{ inflow: number; outflow: number }>(pipeline);
return { inflow: row?.inflow ?? 0, outflow: row?.outflow ?? 0 };
}
async function loadAccount(userId: string, householdId: string, accountId: string) {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const info = ctx.accountMap.get(accountId);
if (!info || info.hidden) return null;
const account = await AccountModel.findOne({
_id: info.id,
householdId: ctx.m.householdId,
}).lean<AccountDoc>();
return account ? { ctx, account } : null;
}
// --- Kredi kartı ---
export type StatementSummary = {
period: Period;
dueDate: string;
debtMinor: number;
minimumMinor: number;
paidMinor: number;
spentMinor: number;
remainingMinor: number;
minimumRemainingMinor: number;
};
export type CardSummary = {
limitMinor: number;
debtMinor: number;
availableMinor: number;
/** Henüz gerçekleşmemiş taksitlerin toplamı (limitten düşer). */
pendingInstallmentsMinor: number;
minimumRateBp: number;
current: Period & { spentMinor: number };
statement: StatementSummary;
daysToDue: number;
history: StatementSummary[];
/** Dönem dönem yaklaşan taksit toplamları. */
upcomingInstallments: { periodEnd: string; dueDate: string; totalMinor: number; count: number }[];
/** Güncel ve sonraki dönemdeki planlı (taksit dışı) harcamalar. */
planned: TransactionListItem[];
paymentAccount: { id: string; name: string } | null;
};
export async function getCardSummary(
userId: string,
householdId: string,
accountId: string,
today = todayIstanbul(),
): Promise<CardSummary | null> {
const loaded = await loadAccount(userId, householdId, accountId);
if (!loaded || loaded.account.type !== "credit_card" || !loaded.account.creditCard) return null;
const { ctx, account } = loaded;
const card = account.creditCard!;
const hh = ctx.m.householdId;
const S = card.statementDay;
const rate = minimumRateBp(card.limitMinor, card.minimumPaymentRateBp);
const { current, statement } = cardPeriods(today, S);
const balanceNow = await balanceBefore(hh, account, null);
const debtMinor = Math.max(0, -balanceNow);
// Son ekstre + geçmiş dönemler (yeniden eskiye).
const periods: Period[] = [statement];
while (periods.length < HISTORY) periods.push(previousPeriod(periods.at(-1)!, S));
const history = await Promise.all(
periods.map(async (p): Promise<StatementSummary> => {
const next = nextPeriod(p, S);
const [debtAt, paidFlows, spentFlows] = await Promise.all([
balanceBefore(hh, account, endExclusive(p.end)),
flows(hh, account._id, endExclusive(p.end), endExclusive(next.end)),
flows(hh, account._id, dayStart(p.start), endExclusive(p.end)),
]);
const debt = Math.max(0, -debtAt);
const minimum = minimumPayment(debt, rate);
return {
period: p,
dueDate: dueDate(p.end, card.dueRule),
debtMinor: debt,
minimumMinor: minimum,
paidMinor: paidFlows.inflow,
spentMinor: spentFlows.outflow,
remainingMinor: Math.max(0, debt - paidFlows.inflow),
minimumRemainingMinor: Math.max(0, minimum - paidFlows.inflow),
};
}),
);
const now = new Date();
await CardStatementModel.bulkWrite(
history.map((h) => ({
updateOne: {
filter: { accountId: account._id, periodEnd: h.period.end },
update: {
$set: {
householdId: hh,
kind: "credit_card",
periodStart: h.period.start,
dueDate: h.dueDate,
statementDebtMinor: h.debtMinor,
minimumPaymentMinor: h.minimumMinor,
paidMinor: h.paidMinor,
spentMinor: h.spentMinor,
computedAt: now,
},
},
upsert: true,
},
})),
);
const currentFlows = await flows(
hh,
account._id,
dayStart(current.start),
endExclusive(current.end),
);
// Planlı taksitler (dönemlerine göre) ve diğer planlı harcamalar.
const planned = (await TransactionModel.find({
householdId: hh,
accountId: account._id,
status: "planned",
deletedAt: null,
})
.sort({ date: 1 })
.lean()) as unknown as LeanTx[];
const byPeriod = new Map<string, { totalMinor: number; count: number }>();
let pendingInstallmentsMinor = 0;
const horizon = nextPeriod(current, S).end;
const other: LeanTx[] = [];
for (const tx of planned) {
if (tx.installment?.planId) {
const end = closingOnOrAfter(toIstanbulYmd(tx.date), S);
const x = byPeriod.get(end) ?? { totalMinor: 0, count: 0 };
x.totalMinor += tx.amountMinor;
x.count++;
byPeriod.set(end, x);
pendingInstallmentsMinor += tx.amountMinor;
} else if (toIstanbulYmd(tx.date) <= horizon && tx.type !== "income") {
other.push(tx);
}
}
const names = await userNames(other.map((t) => t.createdBy as Types.ObjectId));
const statementSummary = history[0]!;
const payer = card.paymentAccountId ? ctx.accountMap.get(String(card.paymentAccountId)) : null;
return {
limitMinor: card.limitMinor,
debtMinor,
availableMinor: card.limitMinor - debtMinor - pendingInstallmentsMinor,
pendingInstallmentsMinor,
minimumRateBp: rate,
current: { ...current, spentMinor: currentFlows.outflow },
statement: statementSummary,
daysToDue: Math.round(
(dayStart(statementSummary.dueDate).getTime() - dayStart(today).getTime()) / DAY_MS,
),
history,
upcomingInstallments: [...byPeriod.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.slice(0, 12)
.map(([periodEnd, x]) => ({ periodEnd, dueDate: dueDate(periodEnd, card.dueRule), ...x })),
planned: other.slice(0, 10).map((tx) => toItem(ctx, tx, names)),
paymentAccount:
payer && !payer.hidden && !payer.archived ? { id: String(payer.id), name: payer.name } : null,
};
}
// --- KMH (esnek hesap) ---
export type OverdraftSummary = {
limitMinor: number;
balanceMinor: number;
usedMinor: number;
availableMinor: number;
monthlyRateBp: number;
current: Period & { interestSoFarMinor: number };
statement: Period & { interestMinor: number; averageUsedMinor: number };
/** Faiz kaydı önerisi için "Banka › Faiz" kategorisi (varsa). */
interestCategoryId: string | null;
/** Kesimden sonra bu hesapta faiz kategorisinde gider girilmiş mi? */
interestBooked: boolean;
};
async function dailyDeltas(
householdId: Types.ObjectId,
accountId: Types.ObjectId,
period: Period,
): Promise<Map<string, number>> {
const rows = await TransactionModel.aggregate<{ _id: string; delta: number }>([
{
$match: {
...accountMatch(householdId, accountId),
date: { $gte: dayStart(period.start), $lt: endExclusive(period.end) },
},
},
{
$group: {
_id: { $dateToString: { format: "%Y-%m-%d", date: "$date", timezone: "Europe/Istanbul" } },
delta: {
$sum: {
$cond: [
{ $eq: ["$type", "transfer"] },
{
$cond: [
{ $eq: ["$accountId", accountId] },
{ $multiply: ["$amountMinor", -1] },
"$amountMinor",
],
},
{
$cond: [
{ $eq: ["$type", "income"] },
"$amountMinor",
{ $multiply: ["$amountMinor", -1] },
],
},
],
},
},
},
},
]);
return new Map(rows.map((r) => [r._id, r.delta]));
}
export async function getOverdraftSummary(
userId: string,
householdId: string,
accountId: string,
today = todayIstanbul(),
): Promise<OverdraftSummary | null> {
const loaded = await loadAccount(userId, householdId, accountId);
const od = loaded?.account.overdraft;
if (!loaded || loaded.account.type !== "bank" || !od?.enabled) return null;
const { ctx, account } = loaded;
const hh = ctx.m.householdId;
const S = od.statementDay ?? 1;
const rate = od.monthlyRateBp ?? 0;
const { current, statement } = cardPeriods(today, S);
const [balanceMinor, stStart, curStart, stDeltas, curDeltas, faiz] = await Promise.all([
balanceBefore(hh, account, null),
balanceBefore(hh, account, dayStart(statement.start)),
balanceBefore(hh, account, dayStart(current.start)),
dailyDeltas(hh, account._id, statement),
dailyDeltas(hh, account._id, current),
CategoryModel.findOne({ householdId: hh, defaultKey: "banka.faiz", archivedAt: null })
.select({ _id: 1 })
.lean(),
]);
const st = estimateOverdraftInterest(statement, stStart, stDeltas, rate);
const cur = estimateOverdraftInterest(current, curStart, curDeltas, rate, today);
const interestBooked = faiz
? Boolean(
await TransactionModel.exists({
householdId: hh,
accountId: account._id,
type: "expense",
effectiveCategoryIds: faiz._id,
deletedAt: null,
status: { $ne: "void" },
date: { $gte: dayStart(statement.end), $lt: endExclusive(addDays(today, 0)) },
}),
)
: false;
await CardStatementModel.updateOne(
{ accountId: account._id, periodEnd: statement.end },
{
$set: {
householdId: hh,
kind: "overdraft",
periodStart: statement.start,
statementDebtMinor: Math.max(
0,
-(await balanceBefore(hh, account, endExclusive(statement.end))),
),
interestMinor: st.interestMinor,
computedAt: new Date(),
},
},
{ upsert: true },
);
return {
limitMinor: od.limitMinor,
balanceMinor,
usedMinor: Math.max(0, -balanceMinor),
// Kullanılabilir KMH: limit − kullanılan (pozitif bakiye KMH'yi artırmaz).
availableMinor: Math.max(0, od.limitMinor - Math.max(0, -balanceMinor)),
monthlyRateBp: rate,
current: { ...current, interestSoFarMinor: cur.interestMinor },
statement: {
...statement,
interestMinor: st.interestMinor,
averageUsedMinor: st.averageUsedMinor,
},
interestCategoryId: faiz ? String(faiz._id) : null,
interestBooked,
};
}
@@ -0,0 +1,314 @@
"use client";
import { ChevronDown, CreditCard, Landmark, Percent, Wallet } from "lucide-react";
import { useState } from "react";
import { useQuickAdd } from "@/components/layout/quick-add";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatDate, formatMoney } from "@/shared/format";
import { formatMoneyInput } from "@/shared/money";
import type { CardSummary, OverdraftSummary } from "../card";
import { Amount, rowTitle } from "./transaction-parts";
const c = tr.card;
const o = tr.overdraft;
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
const pct = (bp: number) => formatMoneyInput(bp).replace(/,00$/, "");
function Stat({
label,
value,
tone,
sub,
}: {
label: string;
value: string;
tone?: string;
sub?: React.ReactNode;
}) {
return (
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={cn("text-base font-semibold tabular-nums", tone)}>{value}</p>
{sub ? <p className="text-xs text-muted-foreground">{sub}</p> : null}
</div>
);
}
/** Limit kullanım çubuğu: borç + gelecek taksitler / limit. */
function UsageBar({ used, pending, limit }: { used: number; pending: number; limit: number }) {
if (limit <= 0) return null;
const u = Math.min(100, (used / limit) * 100);
const p = Math.min(100 - u, (pending / limit) * 100);
return (
<div
className="flex h-2 w-full overflow-hidden rounded-full bg-muted"
role="img"
aria-label={`${Math.round(u + p)}%`}
>
<div className="bg-expense" style={{ width: `${u}%` }} />
<div className="bg-expense/40" style={{ width: `${p}%` }} />
</div>
);
}
export function CardSummaryPanel({
summary: s,
accountId,
canWrite,
}: {
summary: CardSummary;
accountId: string;
canWrite: boolean;
}) {
const { openTransactionForm } = useQuickAdd();
const [historyOpen, setHistoryOpen] = useState(false);
const st = s.statement;
function pay() {
openTransactionForm({
kind: "create",
type: "transfer",
prefill: {
accountId: s.paymentAccount?.id,
toAccountId: accountId,
amountMinor: st.remainingMinor || undefined,
description: c.payDescription,
},
});
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CreditCard className="size-4" aria-hidden />
{c.title}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<div className="grid grid-cols-2 gap-3">
<Stat label={c.debt} value={formatMoney(s.debtMinor)} tone="text-expense" />
<Stat
label={c.available}
value={formatMoney(s.availableMinor)}
sub={c.limitOf(formatMoney(s.limitMinor))}
/>
</div>
<UsageBar used={s.debtMinor} pending={s.pendingInstallmentsMinor} limit={s.limitMinor} />
{s.pendingInstallmentsMinor ? (
<p className="text-xs text-muted-foreground">
{c.pendingInstallments(formatMoney(s.pendingInstallmentsMinor))}
</p>
) : null}
</div>
<section className="flex flex-col gap-3 rounded-xl border p-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-sm font-medium">{c.statementPeriod}</h3>
<span className="text-xs text-muted-foreground">
{c.periodRange(d(st.period.start), d(st.period.end))}
</span>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label={c.statementDebt} value={formatMoney(st.debtMinor)} />
<Stat label={c.minimum(pct(s.minimumRateBp))} value={formatMoney(st.minimumMinor)} />
<Stat label={c.paid} value={formatMoney(st.paidMinor)} tone="text-income" />
<Stat
label={c.remaining}
value={formatMoney(st.remainingMinor)}
tone={st.remainingMinor ? "text-expense" : "text-income"}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm">
{c.dueDate}: <span className="font-medium">{d(st.dueDate)}</span>{" "}
<span
className={cn(
"text-xs",
st.remainingMinor && s.daysToDue < 0 ? "text-expense" : "text-muted-foreground",
)}
>
{st.remainingMinor ? `· ${c.daysLeft(s.daysToDue)}` : `· ${c.paidInFull}`}
</span>
</p>
{canWrite ? (
<Button size="sm" onClick={pay}>
<Wallet />
{c.pay}
</Button>
) : null}
</div>
</section>
<section className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h3 className="text-sm font-medium">{c.currentPeriod}</h3>
<p className="text-xs text-muted-foreground">
{c.periodRange(d(s.current.start), d(s.current.end))}
</p>
</div>
<Stat label={c.spent} value={formatMoney(s.current.spentMinor)} tone="text-expense" />
</section>
<section className="flex flex-col gap-2">
<h3 className="text-sm font-medium">{c.upcomingInstallments}</h3>
{s.upcomingInstallments.length ? (
<ul className="divide-y rounded-xl border text-sm">
{s.upcomingInstallments.map((x) => (
<li key={x.periodEnd} className="flex items-center justify-between gap-3 p-2.5">
<span className="text-muted-foreground">
{c.installmentPeriod(d(x.periodEnd), d(x.dueDate), x.count)}
</span>
<span className="font-medium tabular-nums">{formatMoney(x.totalMinor)}</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">{c.noInstallments}</p>
)}
</section>
{s.planned.length ? (
<section className="flex flex-col gap-2">
<h3 className="text-sm font-medium">{c.planned}</h3>
<ul className="divide-y rounded-xl border text-sm">
{s.planned.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-3 p-2.5">
<span className="min-w-0 truncate">
{d(p.date)} · {rowTitle(p)}
</span>
<Amount type={p.type} amountMinor={p.amountMinor} className="font-medium" />
</li>
))}
</ul>
</section>
) : null}
<Collapsible open={historyOpen} onOpenChange={setHistoryOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="-ml-2 w-fit">
<ChevronDown className={cn("transition-transform", historyOpen && "rotate-180")} />
{c.history}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="text-xs text-muted-foreground">
<tr className="border-b">
<th className="p-2 text-left font-normal">{c.historyHeaders.period}</th>
<th className="p-2 text-right font-normal">{c.historyHeaders.debt}</th>
<th className="p-2 text-right font-normal">{c.historyHeaders.minimum}</th>
<th className="p-2 text-right font-normal">{c.historyHeaders.paid}</th>
<th className="p-2 text-right font-normal">{c.historyHeaders.due}</th>
</tr>
</thead>
<tbody className="divide-y tabular-nums">
{s.history.map((h) => (
<tr key={h.period.end}>
<td className="p-2 whitespace-nowrap">
{c.periodRange(d(h.period.start), d(h.period.end))}
</td>
<td className="p-2 text-right">{formatMoney(h.debtMinor)}</td>
<td className="p-2 text-right">{formatMoney(h.minimumMinor)}</td>
<td className="p-2 text-right">{formatMoney(h.paidMinor)}</td>
<td className="p-2 text-right whitespace-nowrap">{d(h.dueDate)}</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
);
}
export function OverdraftPanel({
summary: s,
accountId,
canWrite,
}: {
summary: OverdraftSummary;
accountId: string;
canWrite: boolean;
}) {
const { openTransactionForm } = useQuickAdd();
const range = c.periodRange(d(s.statement.start), d(s.statement.end));
function bookInterest() {
openTransactionForm({
kind: "create",
type: "expense",
prefill: {
accountId,
amountMinor: s.statement.interestMinor || undefined,
categoryId: s.interestCategoryId ?? undefined,
date: s.statement.end,
description: o.interestDescription(range),
},
});
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Landmark className="size-4" aria-hidden />
{o.title}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-3">
<Stat
label={o.limit}
value={formatMoney(s.limitMinor)}
sub={o.rate(pct(s.monthlyRateBp))}
/>
<Stat
label={o.used}
value={formatMoney(s.usedMinor)}
tone={s.usedMinor ? "text-expense" : undefined}
/>
<Stat label={o.available} value={formatMoney(Math.max(0, s.availableMinor))} />
</div>
<UsageBar used={s.usedMinor} pending={0} limit={s.limitMinor} />
<section className="flex flex-col gap-2 rounded-xl border p-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-sm font-medium">{o.lastPeriod}</h3>
<span className="text-xs text-muted-foreground">{range}</span>
</div>
<div className="flex flex-wrap items-end justify-between gap-3">
<Stat
label={o.estimatedInterest}
value={formatMoney(s.statement.interestMinor)}
tone={s.statement.interestMinor ? "text-expense" : undefined}
sub={o.averageUsed(formatMoney(s.statement.averageUsedMinor))}
/>
{s.interestBooked ? (
<p className="text-xs text-muted-foreground">{o.booked}</p>
) : canWrite && s.statement.interestMinor > 0 ? (
<Button size="sm" variant="outline" onClick={bookInterest}>
<Percent />
{o.bookInterest}
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">{o.taxNote}</p>
</section>
<p className="text-sm">
{o.currentSoFar}:{" "}
<span className="font-medium tabular-nums">
{formatMoney(s.current.interestSoFarMinor)}
</span>
</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,400 @@
"use client";
import {
ArrowLeft,
CheckCheck,
ChevronLeft,
ChevronRight,
List,
Loader2,
Scale,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { MoneyInput } from "@/components/money-input";
import { PageHeader } from "@/components/page-header";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { todayIstanbul } from "@/shared/dates";
import { formatDate, formatMoney } from "@/shared/format";
import { previewReconciliationAction, reconcileAccountAction } from "../actions";
import type { AccountStatement, ReconcilePreview, StatementRow } from "../statement";
import type { CardSummary, OverdraftSummary } from "../card";
import { CardSummaryPanel, OverdraftPanel } from "./card-summary";
import { RowIcon, rowSubtitle, rowTitle, useTransactionDetail } from "./transaction-parts";
const t = tr.statement;
const r = t.reconcile;
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
month: "long",
year: "numeric",
timeZone: "Europe/Istanbul",
});
const monthLabel = (ym: string) => monthFmt.format(new Date(`${ym}-15T12:00:00+03:00`));
const dateLabel = (ymd: string) => formatDate(new Date(`${ymd}T12:00:00+03:00`));
function SignedMoney({ minor, className }: { minor: number; className?: string }) {
return (
<span
className={cn(
"font-semibold whitespace-nowrap tabular-nums",
minor > 0 && "text-income",
minor < 0 && "text-expense",
className,
)}
>
{minor > 0 ? "+" : minor < 0 ? "−" : ""}
{formatMoney(Math.abs(minor))}
</span>
);
}
export function StatementView({
statement: s,
card,
overdraft,
canWrite,
}: {
statement: AccountStatement;
card?: CardSummary | null;
overdraft?: OverdraftSummary | null;
canWrite: boolean;
}) {
const { openDetail, element: detailElement } = useTransactionDetail(canWrite);
const [reconcileOpen, setReconcileOpen] = useState(false);
const base = `/hesaplar/${s.account.id}`;
return (
<div className="flex flex-col gap-4">
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
<Link href="/hesaplar">
<ArrowLeft />
{t.back}
</Link>
</Button>
<PageHeader
title={s.account.name}
description={`${tr.accounts.types[s.account.type]} · ${t.current}: ${formatMoney(s.currentBalanceMinor)}`}
actions={
<>
<Button variant="outline" asChild>
<Link href={`/islemler?hesap=${s.account.id}`}>
<List />
<span className="hidden sm:inline">{t.allTransactions}</span>
</Link>
</Button>
{canWrite && s.canReconcile ? (
<Button onClick={() => setReconcileOpen(true)}>
<Scale />
{r.open}
</Button>
) : null}
</>
}
/>
{card ? (
<CardSummaryPanel summary={card} accountId={s.account.id} canWrite={canWrite} />
) : null}
{overdraft ? (
<OverdraftPanel summary={overdraft} accountId={s.account.id} canWrite={canWrite} />
) : null}
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="icon-sm" asChild aria-label={t.prevMonth}>
<Link href={`${base}?ay=${s.prevMonth}`}>
<ChevronLeft />
</Link>
</Button>
<h2 className="text-base font-medium capitalize">{monthLabel(s.month)}</h2>
{s.nextMonth ? (
<Button variant="outline" size="icon-sm" asChild aria-label={t.nextMonth}>
<Link href={`${base}?ay=${s.nextMonth}`}>
<ChevronRight />
</Link>
</Button>
) : (
<Button variant="outline" size="icon-sm" disabled aria-label={t.nextMonth}>
<ChevronRight />
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{(
[
[t.opening, s.openingMinor, false],
[t.inflow, s.inflowMinor, true],
[t.outflow, -s.outflowMinor, true],
[t.closing, s.closingMinor, false],
] as const
).map(([label, value, colored]) => (
<Card key={label} size="sm">
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-base">
{colored ? (
<SignedMoney minor={value} />
) : (
<span className="tabular-nums">{formatMoney(value)}</span>
)}
</CardTitle>
</CardHeader>
</Card>
))}
</div>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CheckCheck className="size-3.5" aria-hidden />
{s.lastReconciliation
? t.lastReconciliation(
dateLabel(s.lastReconciliation.date),
formatMoney(s.lastReconciliation.statementBalanceMinor),
)
: t.noReconciliation}
</p>
{s.rows.length === 0 ? (
<Card>
<CardHeader>
<CardDescription>{t.empty}</CardDescription>
</CardHeader>
</Card>
) : (
<ul className="divide-y rounded-xl border bg-card">
{s.rows.map((row) => (
<li key={row.item.id}>
<StatementRowView row={row} onOpen={() => openDetail(row.item.id)} />
</li>
))}
</ul>
)}
{s.truncated ? <p className="text-xs text-muted-foreground">{t.truncated}</p> : null}
{detailElement}
<ReconcileDialog
open={reconcileOpen}
onOpenChange={setReconcileOpen}
accountId={s.account.id}
isLiability={s.account.isLiability}
/>
</div>
);
}
function StatementRowView({ row, onOpen }: { row: StatementRow; onOpen: () => void }) {
const content = (
<>
<RowIcon item={row.item} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate font-medium">{rowTitle(row.item)}</span>
{row.reconciled ? (
<CheckCheck className="size-3.5 shrink-0 text-income" aria-label={t.reconciledHint} />
) : null}
</span>
<span className="block truncate text-xs text-muted-foreground">
{dateLabel(row.item.date)} · {rowSubtitle(row.item)}
</span>
</span>
<span className="flex flex-col items-end">
<SignedMoney minor={row.deltaMinor} />
<span className="text-xs text-muted-foreground tabular-nums" title={t.balanceAfter}>
{formatMoney(row.balanceMinor)}
</span>
</span>
</>
);
const cls = "flex w-full items-center gap-3 p-3 text-left";
// Gizli karşı hesaba dokunan işlemin ayrıntısı açılmaz.
return row.restricted ? (
<div className={cls}>{content}</div>
) : (
<button
type="button"
onClick={onOpen}
className={cn(cls, "outline-none hover:bg-muted/50 focus-visible:bg-muted")}
>
{content}
</button>
);
}
function ReconcileDialog({
open,
onOpenChange,
accountId,
isLiability,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
accountId: string;
isLiability: boolean;
}) {
const [date, setDate] = useState(todayIstanbul);
const [balance, setBalance] = useState("");
const [preview, setPreview] = useState<ReconcilePreview | null>(null);
const [accept, setAccept] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [pending, setPending] = useState(false);
function reset(next: boolean) {
if (!next) {
setPreview(null);
setAccept(false);
setErrors({});
setBalance("");
setDate(todayIstanbul());
}
onOpenChange(next);
}
const input = () => ({
statementDate: date,
statementBalance: balance,
acceptAdjustment: accept,
});
async function check() {
setPending(true);
const res = await previewReconciliationAction(accountId, input());
setPending(false);
if (!res.ok) {
setErrors({ ...res.fieldErrors, root: res.fieldErrors ? undefined : res.error });
return;
}
setErrors({});
setAccept(false);
setPreview(res.data);
}
async function confirm() {
setPending(true);
const res = await reconcileAccountAction(accountId, input());
setPending(false);
if (!res.ok) {
setErrors({ root: res.error });
return;
}
toast.success(
res.data.adjustmentId
? r.doneWithAdjustment(res.data.markedCount)
: r.done(res.data.markedCount),
);
reset(false);
}
const diff = preview?.differenceMinor ?? 0;
const display = (minor: number) => formatMoney(isLiability ? -minor : minor);
return (
<ResponsiveDialog
open={open}
onOpenChange={reset}
title={r.title}
description={r.description}
footer={
<>
<Button variant="outline" type="button" onClick={() => reset(false)}>
{tr.common.cancel}
</Button>
{preview ? (
<Button onClick={confirm} disabled={pending || (diff !== 0 && !accept)}>
{pending ? <Loader2 className="animate-spin" /> : null}
{diff !== 0 ? r.confirmWithAdjustment : r.confirm}
</Button>
) : (
<Button onClick={check} disabled={pending || !balance.trim()}>
{pending ? <Loader2 className="animate-spin" /> : null}
{r.check}
</Button>
)}
</>
}
>
<FieldGroup>
<Field data-invalid={Boolean(errors.statementDate)}>
<FieldLabel htmlFor="rec-date">{r.date}</FieldLabel>
<Input
id="rec-date"
type="date"
value={date}
max={todayIstanbul()}
onChange={(e) => {
setDate(e.target.value);
setPreview(null);
}}
/>
{errors.statementDate ? <FieldError>{errors.statementDate}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.statementBalance)}>
<FieldLabel htmlFor="rec-balance">{isLiability ? r.debt : r.balance}</FieldLabel>
<MoneyInput
id="rec-balance"
value={balance}
allowNegative
onChange={(v) => {
setBalance(v);
setPreview(null);
}}
/>
<FieldDescription>{isLiability ? r.debtHint : r.balanceHint}</FieldDescription>
{errors.statementBalance ? <FieldError>{errors.statementBalance}</FieldError> : null}
</Field>
{preview ? (
<div className="flex flex-col gap-3 rounded-xl border p-3" aria-live="polite">
<dl className="grid grid-cols-3 gap-2 text-sm">
<div>
<dt className="text-xs text-muted-foreground">{r.statementBalance}</dt>
<dd className="tabular-nums">{display(preview.statementBalanceMinor)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">{r.appBalance}</dt>
<dd className="tabular-nums">{display(preview.appBalanceMinor)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">{r.difference}</dt>
<dd>
<SignedMoney minor={diff} />
</dd>
</div>
</dl>
{diff === 0 ? (
<p className="text-sm text-income">{r.matches}</p>
) : (
<>
<p className="text-sm text-expense">{r.mismatch}</p>
<label className="flex items-start gap-2 text-sm">
<Checkbox
checked={accept}
onCheckedChange={(v) => setAccept(v === true)}
className="mt-0.5"
/>
<span>
{r.adjustmentOffer(
formatMoney(Math.abs(diff)),
diff > 0 ? tr.transactions.types.income : tr.transactions.types.expense,
)}
</span>
</label>
</>
)}
<Badge variant="secondary" className="w-fit">
{r.unreconciled(preview.unreconciledCount)}
</Badge>
</div>
) : null}
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</ResponsiveDialog>
);
}
@@ -27,6 +27,9 @@ import { cn } from "@/lib/utils";
import { todayIstanbul } from "@/shared/dates"; import { todayIstanbul } from "@/shared/dates";
import { formatMoney } from "@/shared/format"; import { formatMoney } from "@/shared/format";
import { formatMoneyInput, parseMoney } from "@/shared/money"; import { formatMoneyInput, parseMoney } from "@/shared/money";
import { allowedAttachmentKinds } from "@/shared/attachments";
import { AttachmentPicker } from "@/features/attachments/components/attachment-picker";
import type { AttachmentInfo } from "@/features/attachments/service";
import { transactionFormSchema, type TransactionForm } from "@/shared/schemas/transaction"; import { transactionFormSchema, type TransactionForm } from "@/shared/schemas/transaction";
import { MAX_SPLITS, TRANSACTION_TYPES, type TransactionType } from "@/shared/transactions"; import { MAX_SPLITS, TRANSACTION_TYPES, type TransactionType } from "@/shared/transactions";
import { import {
@@ -39,9 +42,22 @@ import type { PayeeSuggestion, TransactionDetail, TransactionFormOptions } from
const t = tr.transactions; const t = tr.transactions;
const f = t.fields; const f = t.fields;
const INSTALLMENT_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 15, 18, 24, 36];
export type TransactionFormMode = export type TransactionFormMode =
| { kind: "create"; type: TransactionType } | {
kind: "create";
type: TransactionType;
/** Ön doldurma (ör. kart borcu ödemesi, KMH faiz kaydı). */
prefill?: {
accountId?: string;
toAccountId?: string;
amountMinor?: number;
categoryId?: string;
date?: string;
description?: string;
};
}
| { kind: "edit"; transaction: TransactionDetail } | { kind: "edit"; transaction: TransactionDetail }
| { kind: "copy"; transaction: TransactionDetail }; | { kind: "copy"; transaction: TransactionDetail };
@@ -57,21 +73,28 @@ type FormValues = {
payee: string; payee: string;
description: string; description: string;
tags: string; tags: string;
/** Önizleme bilgisiyle ekler; sunucuya yalnızca kimlikleri gider. */
attachments: AttachmentInfo[];
/** Taksit sayısı (yalnızca yeni kredi kartı gideri). */
installments: string;
}; };
function toValues(mode: TransactionFormMode, options: TransactionFormOptions | null): FormValues { function toValues(mode: TransactionFormMode, options: TransactionFormOptions | null): FormValues {
if (mode.kind === "create") { if (mode.kind === "create") {
const p = mode.prefill ?? {};
return { return {
type: mode.type, type: mode.type,
amount: "", amount: p.amountMinor ? formatMoneyInput(p.amountMinor) : "",
date: todayIstanbul(), date: p.date ?? todayIstanbul(),
accountId: options?.accounts[0]?.id ?? "", accountId: p.accountId ?? options?.accounts[0]?.id ?? "",
toAccountId: "", toAccountId: p.toAccountId ?? "",
categoryId: "", categoryId: p.categoryId ?? "",
splits: [], splits: [],
payee: "", payee: "",
description: "", description: p.description ?? "",
tags: "", tags: "",
attachments: [],
installments: "1",
}; };
} }
const x = mode.transaction; const x = mode.transaction;
@@ -90,10 +113,13 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
payee: x.payeeName, payee: x.payeeName,
description: x.description, description: x.description,
tags: x.tags.join(", "), tags: x.tags.join(", "),
// Kopyada ekler taşınmaz (fiş o işleme aittir).
attachments: mode.kind === "edit" ? x.attachments : [],
installments: "1",
}; };
} }
function AccountSelect({ export function AccountSelect({
id, id,
value, value,
onChange, onChange,
@@ -140,7 +166,7 @@ function AccountSelect({
); );
} }
function CategorySelect({ export function CategorySelect({
id, id,
value, value,
onChange, onChange,
@@ -191,7 +217,7 @@ function CategorySelect({
} }
/** Satıcı alanı: yazdıkça öneri; seçilince son kullanılan kategori/hesap önerilir. */ /** Satıcı alanı: yazdıkça öneri; seçilince son kullanılan kategori/hesap önerilir. */
function PayeeInput({ export function PayeeInput({
value, value,
onChange, onChange,
onPick, onPick,
@@ -298,6 +324,7 @@ export function TransactionFormDialog({
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const [suggested, setSuggested] = useState(false); const [suggested, setSuggested] = useState(false);
const [uploadBusy, setUploadBusy] = useState(false);
const form = useForm<FormValues, unknown, TransactionForm>({ const form = useForm<FormValues, unknown, TransactionForm>({
// FormValues, şemanın giriş şekliyle birebir (tutarlar string; zod kuruşa çevirir). // FormValues, şemanın giriş şekliyle birebir (tutarlar string; zod kuruşa çevirir).
resolver: zodResolver(transactionFormSchema) as unknown as Resolver< resolver: zodResolver(transactionFormSchema) as unknown as Resolver<
@@ -332,6 +359,7 @@ export function TransactionFormDialog({
const amount = useWatch({ control: form.control, name: "amount" }); const amount = useWatch({ control: form.control, name: "amount" });
const accountId = useWatch({ control: form.control, name: "accountId" }); const accountId = useWatch({ control: form.control, name: "accountId" });
const splitValues = useWatch({ control: form.control, name: "splits" }); const splitValues = useWatch({ control: form.control, name: "splits" });
const attachmentCount = useWatch({ control: form.control, name: "attachments" })?.length ?? 0;
const splitMode = splits.fields.length > 0; const splitMode = splits.fields.length > 0;
// Düzenlemede arşivli/kapalı hesap veya kategori seçenek listesinde yoksa ekle (kaybolmasın). // Düzenlemede arşivli/kapalı hesap veya kategori seçenek listesinde yoksa ekle (kaybolmasın).
@@ -364,6 +392,13 @@ export function TransactionFormDialog({
}, [options, type, mode]); }, [options, type, mode]);
const totalMinor = parseMoney(amount ?? "") ?? 0; const totalMinor = parseMoney(amount ?? "") ?? 0;
const selectedAccountType = options?.accounts.find((a) => a.id === accountId)?.type;
// Taksit: yeni (veya kopya) kredi kartı gideri, bölünmemiş.
const showInstallments =
mode?.kind !== "edit" &&
type === "expense" &&
selectedAccountType === "credit_card" &&
!splitMode;
const splitSum = (splitValues ?? []).reduce((a, s) => a + (parseMoney(s.amount ?? "") ?? 0), 0); const splitSum = (splitValues ?? []).reduce((a, s) => a + (parseMoney(s.amount ?? "") ?? 0), 0);
const remaining = totalMinor - splitSum; const remaining = totalMinor - splitSum;
@@ -431,9 +466,15 @@ export function TransactionFormDialog({
async function onSubmit() { async function onSubmit() {
if (!mode) return; if (!mode) return;
const values = form.getValues(); if (uploadBusy) {
form.setError("root", { message: tr.attachments.waitForUploads });
return;
}
const { attachments, ...values } = form.getValues();
const payload = { const payload = {
...values, ...values,
installments: showInstallments ? values.installments : "1",
attachmentIds: attachments.map((a) => a.id),
toAccountId: values.type === "transfer" ? values.toAccountId : "", toAccountId: values.type === "transfer" ? values.toAccountId : "",
categoryId: values.type === "transfer" || values.splits.length ? "" : values.categoryId, categoryId: values.type === "transfer" || values.splits.length ? "" : values.categoryId,
splits: values.type === "transfer" ? [] : values.splits, splits: values.type === "transfer" ? [] : values.splits,
@@ -479,7 +520,7 @@ export function TransactionFormDialog({
<Button <Button
type="submit" type="submit"
form="transaction-form" form="transaction-form"
disabled={form.formState.isSubmitting || loading} disabled={form.formState.isSubmitting || loading || uploadBusy}
> >
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null} {form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
{tr.common.save} {tr.common.save}
@@ -580,6 +621,42 @@ export function TransactionFormDialog({
</Field> </Field>
)} )}
/> />
{showInstallments ? (
<Controller
name="installments"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-installments">{f.installments}</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="tx-installments" className="w-full">
<SelectValue>
{field.value === "1"
? f.singlePayment
: f.installmentCount(Number(field.value))}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
{INSTALLMENT_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n === 1 ? f.singlePayment : f.installmentCount(n)}
</SelectItem>
))}
</SelectContent>
</Select>
{Number(field.value) > 1 && totalMinor > 0 ? (
<FieldDescription>
{f.installmentHint(
Number(field.value),
formatMoney(Math.ceil(totalMinor / Number(field.value))),
)}
</FieldDescription>
) : null}
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
) : null}
{type === "transfer" ? ( {type === "transfer" ? (
<Controller <Controller
name="toAccountId" name="toAccountId"
@@ -674,10 +751,30 @@ export function TransactionFormDialog({
· {t.list.split(splits.fields.length)} · {t.list.split(splits.fields.length)}
</span> </span>
) : null} ) : null}
{attachmentCount ? (
<span className="text-xs text-muted-foreground">
· {tr.attachments.count(attachmentCount)}
</span>
) : null}
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="pt-3"> <CollapsibleContent className="pt-3">
<FieldGroup> <FieldGroup>
<Controller
name="attachments"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel>{tr.attachments.title}</FieldLabel>
<AttachmentPicker
value={field.value}
onChange={field.onChange}
allowedKinds={allowedAttachmentKinds(type)}
onBusyChange={setUploadBusy}
/>
</Field>
)}
/>
<Controller <Controller
name="tags" name="tags"
control={form.control} control={form.control}
@@ -0,0 +1,385 @@
"use client";
import { ArrowLeftRight, CheckCheck, Copy, Pencil, Scale, Split, Trash2 } from "lucide-react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { IconBadge } from "@/components/appearance/swatch";
import { useQuickAdd } from "@/components/layout/quick-add";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { AttachmentStrip } from "@/features/attachments/components/attachment-strip";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatDate, formatDateTime, formatMoney } from "@/shared/format";
import type { TransactionType } from "@/shared/transactions";
import {
cancelRemainingInstallmentsAction,
getTransactionAction,
setTransactionDeletedAction,
} from "../actions";
import type { TransactionDetail, TransactionListItem } from "../service";
/** İşlem listesi, ayrıntı ve hesap ekstresinin paylaştığı parçalar. */
const t = tr.transactions;
const weekday = new Intl.DateTimeFormat("tr-TR", { weekday: "long", timeZone: "Europe/Istanbul" });
/** "2026-11-05" → "05.11.2026 · Perşembe" */
export function dayLabel(ymd: string) {
const d = new Date(`${ymd}T12:00:00+03:00`);
return `${formatDate(d)} · ${weekday.format(d)}`;
}
export function signed(type: TransactionType, amountMinor: number) {
return type === "income" ? amountMinor : type === "expense" ? -amountMinor : 0;
}
export function Amount({
type,
amountMinor,
className,
}: {
type: TransactionType;
amountMinor: number;
className?: string;
}) {
const text =
type === "income"
? `+${formatMoney(amountMinor)}`
: type === "expense"
? `−${formatMoney(amountMinor)}`
: formatMoney(amountMinor);
return (
<span
className={cn(
"font-semibold whitespace-nowrap tabular-nums",
type === "income" && "text-income",
type === "expense" && "text-expense",
type === "transfer" && "text-muted-foreground",
className,
)}
>
{text}
</span>
);
}
export function RowIcon({ item }: { item: TransactionListItem }) {
if (item.type === "transfer") {
return (
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ArrowLeftRight className="size-4.5" />
</span>
);
}
if (item.isAdjustment) {
return (
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Scale className="size-4.5" />
</span>
);
}
if (item.splitCount) {
return (
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-budget/15 text-budget">
<Split className="size-4.5" />
</span>
);
}
return item.category ? (
<IconBadge icon={item.category.icon} color={item.category.color} />
) : (
<IconBadge icon="tag" color="slate" />
);
}
export function rowTitle(item: TransactionListItem) {
const base = baseTitle(item);
// Taksit: "Hepsiburada 4/9"
return item.installment ? `${base} ${item.installment.index}/${item.installment.count}` : base;
}
function baseTitle(item: TransactionListItem) {
if (item.isAdjustment) return t.adjustment;
if (item.payeeName) return item.payeeName;
if (item.description) return item.description;
if (item.type === "transfer") return t.types.transfer;
return item.category?.name ?? t.types[item.type];
}
export function rowSubtitle(item: TransactionListItem) {
if (item.type === "transfer")
return t.list.transfer(item.account.name, item.toAccount?.name ?? "—");
const what = item.isAdjustment
? item.description
: item.splitCount
? t.list.split(item.splitCount)
: (item.category?.path ?? "—");
return `${what} · ${item.account.name}`;
}
function DetailDialog({
detail,
loading,
canWrite,
onOpenChange,
onEdit,
onCopy,
onDelete,
onCancelInstallments,
}: {
detail: TransactionDetail | null;
loading: boolean;
canWrite: boolean;
onOpenChange: (o: boolean) => void;
onEdit: (d: TransactionDetail) => void;
onCopy: (d: TransactionDetail) => void;
onDelete: (d: TransactionDetail) => void;
onCancelInstallments: (d: TransactionDetail) => void;
}) {
const d = t.detail;
const row = (label: string, value: React.ReactNode) => (
<div className="flex items-start justify-between gap-4 py-2 text-sm">
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-right">{value}</dd>
</div>
);
return (
<ResponsiveDialog
open={loading || Boolean(detail)}
onOpenChange={onOpenChange}
title={d.title}
footer={
detail && canWrite ? (
<>
<Button variant="destructive" onClick={() => onDelete(detail)}>
<Trash2 />
{t.actions.delete}
</Button>
{/* Mutabakat düzeltmesi düzenlenemez/kopyalanamaz; yalnızca silinebilir. */}
{detail.isAdjustment ? null : (
<>
<Button variant="outline" onClick={() => onCopy(detail)}>
<Copy />
{t.actions.copy}
</Button>
<Button onClick={() => onEdit(detail)}>
<Pencil />
{t.actions.edit}
</Button>
</>
)}
</>
) : null
}
>
{!detail ? (
<div className="flex flex-col gap-3" role="status" aria-label={tr.common.loading}>
<Skeleton className="h-10 w-1/2" />
<Skeleton className="h-32 w-full" />
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<RowIcon item={detail} />
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{rowTitle(detail)}</p>
<p className="text-xs text-muted-foreground">{dayLabel(detail.date)}</p>
</div>
<Amount type={detail.type} amountMinor={detail.amountMinor} className="text-lg" />
</div>
<dl className="divide-y border-y">
{row(t.filters.type, <Badge variant="secondary">{t.types[detail.type]}</Badge>)}
{detail.status !== "posted" ? row(t.filters.status, t.statuses[detail.status]) : null}
{row(
detail.type === "transfer" ? t.fields.fromAccount : t.fields.account,
detail.account.name,
)}
{detail.toAccount ? row(t.fields.toAccount, detail.toAccount.name) : null}
{detail.category ? row(t.fields.category, detail.category.path) : null}
{detail.isAdjustment ? row(t.fields.category, t.adjustment) : null}
{detail.payeeName ? row(t.fields.payee, detail.payeeName) : null}
{detail.description ? row(t.fields.description, detail.description) : null}
{detail.tags.length
? row(
t.fields.tags,
<span className="flex flex-wrap justify-end gap-1">
{detail.tags.map((tag) => (
<Badge key={tag} variant="outline">
#{tag}
</Badge>
))}
</span>,
)
: null}
{detail.attachments.length
? row(
tr.attachments.title,
<span className="flex justify-end">
<AttachmentStrip items={detail.attachments} />
</span>,
)
: null}
{detail.installment
? row(
t.fields.installments,
<span className="flex flex-col items-end gap-1">
<span>
{detail.installment.index}/{detail.installment.count}
</span>
{canWrite &&
(detail.status === "planned" ||
detail.installment.index < detail.installment.count) ? (
<Button
size="sm"
variant="outline"
onClick={() => onCancelInstallments(detail)}
>
{t.cancelInstallments}
</Button>
) : null}
</span>,
)
: null}
{detail.reconciledAccountIds.length
? row(
tr.statement.reconcile.title,
<span className="inline-flex items-center gap-1 text-income">
<CheckCheck className="size-4" aria-hidden />
{t.reconciled}
</span>,
)
: null}
{row(d.createdBy, `${detail.createdBy.name} · ${formatDateTime(detail.createdAt)}`)}
{new Date(detail.updatedAt).getTime() - new Date(detail.createdAt).getTime() > 1000
? row(d.updatedBy, `${detail.updatedBy.name} · ${formatDateTime(detail.updatedAt)}`)
: null}
</dl>
{detail.splits.length ? (
<section aria-labelledby="tx-lines">
<h3 id="tx-lines" className="mb-1 text-sm font-medium">
{d.lines}
</h3>
<ul className="divide-y rounded-lg border">
{detail.splits.map((s, i) => (
<li key={i} className="flex items-center gap-3 p-2 text-sm">
<IconBadge icon={s.icon} color={s.color} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate">{s.path}</span>
{s.note ? (
<span className="block truncate text-xs text-muted-foreground">
{s.note}
</span>
) : null}
</span>
<span className="tabular-nums">{formatMoney(s.amountMinor)}</span>
</li>
))}
</ul>
</section>
) : null}
</div>
)}
</ResponsiveDialog>
);
}
/**
* Ayrıntı penceresi + silme onayı + geri al bildirimi. Liste ve ekstre aynı davranışı kullanır;
* düzenle/kopyala uygulama genelindeki işlem formunu açar.
*/
export function useTransactionDetail(canWrite: boolean) {
const { openTransactionForm } = useQuickAdd();
const [detail, setDetail] = useState<TransactionDetail | null>(null);
const [loading, setLoading] = useState(false);
const [deleting, setDeleting] = useState<TransactionDetail | null>(null);
const [, startTransition] = useTransition();
async function openDetail(id: string) {
setLoading(true);
setDetail(null);
const res = await getTransactionAction(id);
setLoading(false);
if (res.ok) setDetail(res.data);
else toast.error(res.error);
}
function close() {
setDetail(null);
setLoading(false);
}
function remove(d: TransactionDetail) {
setDeleting(null);
close();
startTransition(async () => {
const res = await setTransactionDeletedAction(d.id, true);
if (!res.ok) return void toast.error(res.error);
toast.success(t.toast.deleted, {
duration: 10_000,
action: {
label: t.actions.restore,
onClick: async () => {
const r = await setTransactionDeletedAction(d.id, false);
if (r.ok) toast.success(t.toast.restored);
else toast.error(r.error);
},
},
});
});
}
const element = (
<>
<DetailDialog
detail={detail}
loading={loading}
canWrite={canWrite}
onOpenChange={(o) => !o && close()}
onEdit={(d) => {
close();
openTransactionForm({ kind: "edit", transaction: d });
}}
onCopy={(d) => {
close();
openTransactionForm({ kind: "copy", transaction: d });
}}
onDelete={setDeleting}
onCancelInstallments={(d) => {
close();
startTransition(async () => {
const r = await cancelRemainingInstallmentsAction(d.id);
if (r.ok) toast.success(t.toast.installmentsCancelled(r.data.cancelled));
else toast.error(r.error);
});
}}
/>
<AlertDialog open={Boolean(deleting)} onOpenChange={(o) => !o && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
<AlertDialogDescription>{t.deleteDescription}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button variant="destructive" onClick={() => deleting && remove(deleting)}>
{t.actions.delete}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
return { openDetail, element };
}
@@ -1,36 +1,12 @@
"use client"; "use client";
import { import { ChevronLeft, ChevronRight, Download, Filter, Paperclip, Plus, X } from "lucide-react";
ArrowLeftRight,
ChevronLeft,
ChevronRight,
Copy,
Download,
Filter,
Paperclip,
Pencil,
Plus,
Split,
Trash2,
X,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useState, useTransition } from "react"; import { useState } from "react";
import { toast } from "sonner";
import { IconBadge } from "@/components/appearance/swatch";
import { useQuickAdd } from "@/components/layout/quick-add"; import { useQuickAdd } from "@/components/layout/quick-add";
import { MoneyInput } from "@/components/money-input"; import { MoneyInput } from "@/components/money-input";
import { ResponsiveDialog } from "@/components/responsive-dialog"; import { ResponsiveDialog } from "@/components/responsive-dialog";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -43,103 +19,24 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatDate, formatDateTime, formatMoney } from "@/shared/format"; import { formatMoney } from "@/shared/format";
import type { TransactionFilters } from "@/shared/schemas/transaction"; import type { TransactionFilters } from "@/shared/schemas/transaction";
import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions";
import type { TransactionFormOptions, TransactionListItem, TransactionPage } from "../service";
import { import {
TRANSACTION_STATUSES, Amount,
TRANSACTION_TYPES, dayLabel,
type TransactionType, RowIcon,
} from "@/shared/transactions"; rowSubtitle,
import { getTransactionAction, setTransactionDeletedAction } from "../actions"; rowTitle,
import type { signed,
TransactionDetail, useTransactionDetail,
TransactionFormOptions, } from "./transaction-parts";
TransactionListItem,
TransactionPage,
} from "../service";
const t = tr.transactions; const t = tr.transactions;
const ALL = "__all__"; const ALL = "__all__";
const weekday = new Intl.DateTimeFormat("tr-TR", { weekday: "long", timeZone: "Europe/Istanbul" });
/** "2026-11-05" → "05.11.2026 · Perşembe" */
function dayLabel(ymd: string) {
const d = new Date(`${ymd}T12:00:00+03:00`);
return `${formatDate(d)} · ${weekday.format(d)}`;
}
function signed(type: TransactionType, amountMinor: number) {
return type === "income" ? amountMinor : type === "expense" ? -amountMinor : 0;
}
function Amount({
type,
amountMinor,
className,
}: {
type: TransactionType;
amountMinor: number;
className?: string;
}) {
const text =
type === "income"
? `+${formatMoney(amountMinor)}`
: type === "expense"
? `−${formatMoney(amountMinor)}`
: formatMoney(amountMinor);
return (
<span
className={cn(
"font-semibold whitespace-nowrap tabular-nums",
type === "income" && "text-income",
type === "expense" && "text-expense",
type === "transfer" && "text-muted-foreground",
className,
)}
>
{text}
</span>
);
}
function RowIcon({ item }: { item: TransactionListItem }) {
if (item.type === "transfer") {
return (
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ArrowLeftRight className="size-4.5" />
</span>
);
}
if (item.splitCount) {
return (
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-budget/15 text-budget">
<Split className="size-4.5" />
</span>
);
}
return item.category ? (
<IconBadge icon={item.category.icon} color={item.category.color} />
) : (
<IconBadge icon="tag" color="slate" />
);
}
function rowTitle(item: TransactionListItem) {
if (item.payeeName) return item.payeeName;
if (item.description) return item.description;
if (item.type === "transfer") return t.types.transfer;
return item.category?.name ?? t.types[item.type];
}
function rowSubtitle(item: TransactionListItem) {
if (item.type === "transfer")
return t.list.transfer(item.account.name, item.toAccount?.name ?? "—");
const what = item.splitCount ? t.list.split(item.splitCount) : (item.category?.path ?? "—");
return `${what} · ${item.account.name}`;
}
// --- Filtreler --- // --- Filtreler ---
@@ -318,126 +215,6 @@ function FiltersDialog({
// --- Ayrıntı --- // --- Ayrıntı ---
function DetailDialog({
detail,
loading,
canWrite,
onOpenChange,
onEdit,
onCopy,
onDelete,
}: {
detail: TransactionDetail | null;
loading: boolean;
canWrite: boolean;
onOpenChange: (o: boolean) => void;
onEdit: (d: TransactionDetail) => void;
onCopy: (d: TransactionDetail) => void;
onDelete: (d: TransactionDetail) => void;
}) {
const d = t.detail;
const row = (label: string, value: React.ReactNode) => (
<div className="flex items-start justify-between gap-4 py-2 text-sm">
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-right">{value}</dd>
</div>
);
return (
<ResponsiveDialog
open={loading || Boolean(detail)}
onOpenChange={onOpenChange}
title={d.title}
footer={
detail && canWrite ? (
<>
<Button variant="destructive" onClick={() => onDelete(detail)}>
<Trash2 />
{t.actions.delete}
</Button>
<Button variant="outline" onClick={() => onCopy(detail)}>
<Copy />
{t.actions.copy}
</Button>
<Button onClick={() => onEdit(detail)}>
<Pencil />
{t.actions.edit}
</Button>
</>
) : null
}
>
{!detail ? (
<div className="flex flex-col gap-3" role="status" aria-label={tr.common.loading}>
<Skeleton className="h-10 w-1/2" />
<Skeleton className="h-32 w-full" />
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<RowIcon item={detail} />
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{rowTitle(detail)}</p>
<p className="text-xs text-muted-foreground">{dayLabel(detail.date)}</p>
</div>
<Amount type={detail.type} amountMinor={detail.amountMinor} className="text-lg" />
</div>
<dl className="divide-y border-y">
{row(t.filters.type, <Badge variant="secondary">{t.types[detail.type]}</Badge>)}
{detail.status !== "posted" ? row(t.filters.status, t.statuses[detail.status]) : null}
{row(
detail.type === "transfer" ? t.fields.fromAccount : t.fields.account,
detail.account.name,
)}
{detail.toAccount ? row(t.fields.toAccount, detail.toAccount.name) : null}
{detail.category ? row(t.fields.category, detail.category.path) : null}
{detail.payeeName ? row(t.fields.payee, detail.payeeName) : null}
{detail.description ? row(t.fields.description, detail.description) : null}
{detail.tags.length
? row(
t.fields.tags,
<span className="flex flex-wrap justify-end gap-1">
{detail.tags.map((tag) => (
<Badge key={tag} variant="outline">
#{tag}
</Badge>
))}
</span>,
)
: null}
{row(d.createdBy, `${detail.createdBy.name} · ${formatDateTime(detail.createdAt)}`)}
{new Date(detail.updatedAt).getTime() - new Date(detail.createdAt).getTime() > 1000
? row(d.updatedBy, `${detail.updatedBy.name} · ${formatDateTime(detail.updatedAt)}`)
: null}
</dl>
{detail.splits.length ? (
<section aria-labelledby="tx-lines">
<h3 id="tx-lines" className="mb-1 text-sm font-medium">
{d.lines}
</h3>
<ul className="divide-y rounded-lg border">
{detail.splits.map((s, i) => (
<li key={i} className="flex items-center gap-3 p-2 text-sm">
<IconBadge icon={s.icon} color={s.color} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate">{s.path}</span>
{s.note ? (
<span className="block truncate text-xs text-muted-foreground">
{s.note}
</span>
) : null}
</span>
<span className="tabular-nums">{formatMoney(s.amountMinor)}</span>
</li>
))}
</ul>
</section>
) : null}
</div>
)}
</ResponsiveDialog>
);
}
// --- Ana görünüm --- // --- Ana görünüm ---
export function TransactionsView({ export function TransactionsView({
@@ -454,12 +231,9 @@ export function TransactionsView({
canWrite: boolean; canWrite: boolean;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const { openQuickAdd, openTransactionForm } = useQuickAdd(); const { openQuickAdd } = useQuickAdd();
const [filtersOpen, setFiltersOpen] = useState(false); const [filtersOpen, setFiltersOpen] = useState(false);
const [detail, setDetail] = useState<TransactionDetail | null>(null); const { openDetail, element: detailElement } = useTransactionDetail(canWrite);
const [detailLoading, setDetailLoading] = useState(false);
const [deleting, setDeleting] = useState<TransactionDetail | null>(null);
const [, startTransition] = useTransition();
const active = countActive(filters); const active = countActive(filters);
const pageHref = (page: number) => { const pageHref = (page: number) => {
@@ -474,40 +248,6 @@ export function TransactionsView({
return `/islemler/disa-aktar${params.size ? `?${params}` : ""}`; return `/islemler/disa-aktar${params.size ? `?${params}` : ""}`;
}; };
async function openDetail(id: string) {
setDetailLoading(true);
setDetail(null);
const res = await getTransactionAction(id);
setDetailLoading(false);
if (res.ok) setDetail(res.data);
else toast.error(res.error);
}
function closeDetail() {
setDetail(null);
setDetailLoading(false);
}
function remove(d: TransactionDetail) {
setDeleting(null);
closeDetail();
startTransition(async () => {
const res = await setTransactionDeletedAction(d.id, true);
if (!res.ok) return void toast.error(res.error);
toast.success(t.toast.deleted, {
duration: 10_000,
action: {
label: t.actions.restore,
onClick: async () => {
const r = await setTransactionDeletedAction(d.id, false);
if (r.ok) toast.success(t.toast.restored);
else toast.error(r.error);
},
},
});
});
}
// Sayfadaki işlemleri güne göre grupla (liste zaten tarihe göre sıralı). // Sayfadaki işlemleri güne göre grupla (liste zaten tarihe göre sıralı).
const groups: { date: string; items: TransactionListItem[] }[] = []; const groups: { date: string; items: TransactionListItem[] }[] = [];
for (const item of data.items) { for (const item of data.items) {
@@ -679,35 +419,7 @@ export function TransactionsView({
raw={raw} raw={raw}
options={options} options={options}
/> />
<DetailDialog {detailElement}
detail={detail}
loading={detailLoading}
canWrite={canWrite}
onOpenChange={(o) => !o && closeDetail()}
onEdit={(d) => {
closeDetail();
openTransactionForm({ kind: "edit", transaction: d });
}}
onCopy={(d) => {
closeDetail();
openTransactionForm({ kind: "copy", transaction: d });
}}
onDelete={setDeleting}
/>
<AlertDialog open={Boolean(deleting)} onOpenChange={(o) => !o && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
<AlertDialogDescription>{t.deleteDescription}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button variant="destructive" onClick={() => deleting && remove(deleting)}>
{t.actions.delete}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
@@ -51,6 +51,8 @@ function form(over: Partial<TransactionForm>): TransactionForm {
payee: "", payee: "",
description: "", description: "",
tags: [], tags: [],
attachmentIds: [],
installments: 1,
...over, ...over,
}; };
} }
@@ -83,6 +85,7 @@ describe("bakiye tutarlılığı", () => {
cardDueKind: "daysAfter" as const, cardDueKind: "daysAfter" as const,
cardDueValue: 10, cardDueValue: 10,
cardPaymentAccountId: null, cardPaymentAccountId: null,
cardMinimumRate: null,
}; };
const b = await createAccount(ids.user("ayse"), cakir, { const b = await createAccount(ids.user("ayse"), cakir, {
...base, ...base,
+215 -29
View File
@@ -1,5 +1,10 @@
import "server-only"; import "server-only";
import { Types, type PipelineStage } from "mongoose"; import mongoose, { Types, type ClientSession, type PipelineStage } from "mongoose";
import {
listOwnerAttachments,
syncOwnerAttachments,
type AttachmentInfo,
} from "@/features/attachments/service";
import { resolveMembership } from "@/features/households/service"; import { resolveMembership } from "@/features/households/service";
import { buildTree, toOptions, type CategoryOption } from "@/features/categories/tree"; import { buildTree, toOptions, type CategoryOption } from "@/features/categories/tree";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
@@ -12,8 +17,10 @@ import { CategoryModel } from "@/models/category";
import { PayeeModel } from "@/models/payee"; import { PayeeModel } from "@/models/payee";
import { TransactionModel } from "@/models/transaction"; import { TransactionModel } from "@/models/transaction";
import type { AccountType } from "@/shared/accounts"; import type { AccountType } from "@/shared/accounts";
import { allowedAttachmentKinds } from "@/shared/attachments";
import type { IconKey, Swatch } from "@/shared/appearance"; import type { IconKey, Swatch } from "@/shared/appearance";
import { istanbulDayStart, toIstanbulYmd } from "@/shared/dates"; import { installmentSchedule } from "@/shared/card-periods";
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
import type { CategoryKind } from "@/shared/schemas/category"; import type { CategoryKind } from "@/shared/schemas/category";
import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction"; import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction";
import { compareTr, foldTr } from "@/shared/search"; import { compareTr, foldTr } from "@/shared/search";
@@ -52,9 +59,15 @@ type CategoryInfo = {
ancestors: string[]; ancestors: string[];
}; };
async function loadContext(userId: string, householdId: string, minRole: "viewer" | "member") { /** Hane bağlamı (hesap/kategori haritaları, gizli hesaplar). İşlemler modülü içinde paylaşılır. */
export async function loadContext(
userId: string,
householdId: string,
minRole: "viewer" | "member",
) {
const m = await resolveMembership(userId, householdId, minRole); const m = await resolveMembership(userId, householdId, minRole);
if (!m) return null; if (!m) return null;
await postDueInstallments(m.householdId);
const [accounts, categories] = await Promise.all([ const [accounts, categories] = await Promise.all([
AccountModel.find({ householdId: m.householdId }) AccountModel.find({ householdId: m.householdId })
.select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 }) .select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 })
@@ -116,10 +129,10 @@ async function loadContext(userId: string, householdId: string, minRole: "viewer
return { m, accountMap, categoryMap, hiddenAccountIds, flatCategories: flat }; return { m, accountMap, categoryMap, hiddenAccountIds, flatCategories: flat };
} }
type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>; export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
/** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */ /** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */
function baseFilter(ctx: Ctx): Record<string, unknown> { export function baseFilter(ctx: Ctx): Record<string, unknown> {
const f: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null }; const f: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null };
if (ctx.hiddenAccountIds.length) { if (ctx.hiddenAccountIds.length) {
f.accountId = { $nin: ctx.hiddenAccountIds }; f.accountId = { $nin: ctx.hiddenAccountIds };
@@ -134,9 +147,28 @@ function baseFilter(ctx: Ctx): Record<string, unknown> {
* Hesap bazında işlem etkileri toplamı (kuruş): yalnızca posted ve silinmemiş işlemler. * Hesap bazında işlem etkileri toplamı (kuruş): yalnızca posted ve silinmemiş işlemler.
* transactionEffects ile aynı kural: gelir +, gider −, transfer kaynak − / hedef +. * transactionEffects ile aynı kural: gelir +, gider −, transfer kaynak − / hedef +.
*/ */
/**
* Vadesi gelen taksitleri gerçekleştirir (planlı → posted). Okuma yollarında çağrılır; zamanlanmış
* görev gerekmez. Taksitler onay beklemez (Faz 7 kararı: kendi döneminde otomatik).
*/
export async function postDueInstallments(householdId: Types.ObjectId, today = todayIstanbul()) {
const tomorrow = istanbulDayStart(today)!.getTime() + DAY_MS;
await TransactionModel.updateMany(
{
householdId,
status: "planned",
deletedAt: null,
"installment.planId": { $type: "objectId" },
date: { $lt: new Date(tomorrow) },
},
{ $set: { status: "posted" } },
);
}
export async function computeAccountDeltas( export async function computeAccountDeltas(
householdId: Types.ObjectId, householdId: Types.ObjectId,
): Promise<Map<string, number>> { ): Promise<Map<string, number>> {
await postDueInstallments(householdId);
const pipeline: PipelineStage[] = [ const pipeline: PipelineStage[] = [
{ $match: { householdId, status: "posted", deletedAt: null } }, { $match: { householdId, status: "posted", deletedAt: null } },
{ {
@@ -195,7 +227,7 @@ type Existing = {
splits?: { categoryId: Types.ObjectId }[]; splits?: { categoryId: Types.ObjectId }[];
}; };
function buildDoc( export function buildDoc(
ctx: Ctx, ctx: Ctx,
input: TransactionForm, input: TransactionForm,
existing?: Existing, existing?: Existing,
@@ -269,7 +301,7 @@ function buildDoc(
} }
/** Satıcı kuralını günceller (son kategori ve hesap) ve kimliğini döner. */ /** Satıcı kuralını günceller (son kategori ve hesap) ve kimliğini döner. */
async function touchPayee( export async function touchPayee(
householdId: Types.ObjectId, householdId: Types.ObjectId,
name: string, name: string,
type: TransactionType, type: TransactionType,
@@ -307,6 +339,11 @@ export async function createTransaction(
const built = buildDoc(ctx, input); const built = buildDoc(ctx, input);
if (!built.ok) return built; if (!built.ok) return built;
const d = built.data as { accountId: Types.ObjectId; categoryId: Types.ObjectId | null }; const d = built.data as { accountId: Types.ObjectId; categoryId: Types.ObjectId | null };
if (input.installments > 1 && ctx.accountMap.get(String(d.accountId))?.type !== "credit_card") {
return fail(t.validation.installmentsCardOnly, {
installments: t.validation.installmentsCardOnly,
});
}
// Bölünmüş işlemde tek bir kategori önerilemez; satıcı kuralı yalnızca hesabı hatırlar. // Bölünmüş işlemde tek bir kategori önerilemez; satıcı kuralı yalnızca hesabı hatırlar.
const payeeId = await touchPayee( const payeeId = await touchPayee(
ctx.m.householdId, ctx.m.householdId,
@@ -316,18 +353,76 @@ export async function createTransaction(
d.categoryId, d.categoryId,
); );
const actor = new Types.ObjectId(actorId); const actor = new Types.ObjectId(actorId);
const doc = await TransactionModel.create({ try {
...built.data, let id = "";
householdId: ctx.m.householdId, await mongoose.connection.transaction(async (session) => {
status: "posted", const base = {
payeeId, ...built.data,
createdBy: actor, householdId: ctx.m.householdId,
updatedBy: actor, status: "posted",
}); payeeId,
return ok({ id: String(doc._id) }); createdBy: actor,
updatedBy: actor,
};
// Taksitli: her taksit ayrı işlem; vadesi gelmeyenler planlı (kendi döneminde gerçekleşir).
const today = todayIstanbul();
const planId = new Types.ObjectId();
const docs =
input.installments > 1
? installmentSchedule(input.date, input.amount, input.installments).map((x) => ({
...base,
amountMinor: x.amountMinor,
date: istanbulDayStart(x.date)!,
status: x.date <= today ? "posted" : "planned",
installment: { planId, index: x.index + 1, count: input.installments },
}))
: [base];
const created = await TransactionModel.insertMany(docs as Record<string, unknown>[], {
session,
});
const first = created[0]!;
id = String(first._id);
await attach(ctx, actorId, first._id, input, session);
});
return ok({ id });
} catch (error) {
if (error instanceof AttachmentError) return error.result;
throw error;
}
} }
async function loadOwnTransaction( /** Ek senkronizasyonu başarısızsa transaction'ı geri almak için fırlatılır. */
class AttachmentError extends Error {
constructor(readonly result: ActionResult<never>) {
super(result.ok ? "" : result.error);
}
}
async function attach(
ctx: Ctx,
actorId: string,
transactionId: Types.ObjectId,
input: TransactionForm,
session: ClientSession,
) {
const synced = await syncOwnerAttachments({
householdId: ctx.m.householdId,
actorId,
ownerType: "transaction",
ownerId: transactionId,
ids: input.attachmentIds,
allowedKinds: allowedAttachmentKinds(input.type),
session,
});
if (!synced.ok) throw new AttachmentError(synced);
await TransactionModel.updateOne(
{ _id: transactionId, householdId: ctx.m.householdId },
{ $set: { attachmentCount: synced.data } },
{ session },
);
}
export async function loadOwnTransaction(
ctx: Ctx, ctx: Ctx,
transactionId: string, transactionId: string,
{ includeDeleted = false } = {}, { includeDeleted = false } = {},
@@ -349,9 +444,17 @@ export async function updateTransaction(
if (!ctx) return fail(tr.common.unauthorized); if (!ctx) return fail(tr.common.unauthorized);
const current = await loadOwnTransaction(ctx, transactionId); const current = await loadOwnTransaction(ctx, transactionId);
if (!current) return fail(tr.common.notFound); if (!current) return fail(tr.common.notFound);
if (current.isAdjustment) return fail(t.adjustmentReadOnly);
const built = buildDoc(ctx, input, current); const built = buildDoc(ctx, input, current);
if (!built.ok) return built; if (!built.ok) return built;
const d = built.data as { accountId: Types.ObjectId; categoryId: Types.ObjectId | null }; const d = built.data as {
type: TransactionType;
date: Date;
amountMinor: number;
accountId: Types.ObjectId;
toAccountId: Types.ObjectId | null;
categoryId: Types.ObjectId | null;
};
const payeeId = await touchPayee( const payeeId = await touchPayee(
ctx.m.householdId, ctx.m.householdId,
input.payee, input.payee,
@@ -359,10 +462,35 @@ export async function updateTransaction(
d.accountId, d.accountId,
d.categoryId, d.categoryId,
); );
await TransactionModel.updateOne( // Bakiyeyi etkileyen bir alan değiştiyse mutabakat işareti geçersizleşir.
{ _id: current._id, householdId: ctx.m.householdId }, const balanceChanged =
{ $set: { ...built.data, payeeId, updatedBy: new Types.ObjectId(actorId) } }, d.type !== current.type ||
); d.amountMinor !== current.amountMinor ||
d.date.getTime() !== new Date(current.date).getTime() ||
!d.accountId.equals(current.accountId) ||
String(d.toAccountId ?? "") !== String(current.toAccountId ?? "");
try {
await mongoose.connection.transaction(async (session) => {
await TransactionModel.updateOne(
{ _id: current._id, householdId: ctx.m.householdId },
{
$set: {
...built.data,
payeeId,
updatedBy: new Types.ObjectId(actorId),
...(balanceChanged ? { reconciledAccountIds: [] } : {}),
// Serinin planlı tekrarı tek başına düzenlendi ("yalnızca bu").
...(current.recurringRuleId && current.status === "planned" ? { detached: true } : {}),
},
},
{ session },
);
await attach(ctx, actorId, current._id, input, session);
});
} catch (error) {
if (error instanceof AttachmentError) return error.result;
throw error;
}
return ok(undefined); return ok(undefined);
} }
@@ -396,6 +524,41 @@ export async function setTransactionDeleted(
return ok(undefined); return ok(undefined);
} }
/**
* Kalan (henüz gerçekleşmemiş) taksitleri iptal eder — ör. ürün iadesi veya erken kapama.
* Gerçekleşmiş taksitlere dokunulmaz.
*/
export async function cancelRemainingInstallments(
actorId: string,
householdId: string,
transactionId: string,
): Promise<ActionResult<{ cancelled: number }>> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const tx = await loadOwnTransaction(ctx, transactionId);
const planId = tx?.installment?.planId;
if (!tx || !planId) return fail(tr.common.notFound);
const actor = new Types.ObjectId(actorId);
const res = await TransactionModel.updateMany(
{
householdId: ctx.m.householdId,
"installment.planId": planId,
status: "planned",
deletedAt: null,
},
{ $set: { deletedAt: new Date(), updatedBy: actor } },
);
await writeAudit({
action: "transaction.delete",
actorId: actor,
householdId: ctx.m.householdId,
targetType: "installmentPlan",
targetId: String(planId),
diff: { cancelled: res.modifiedCount, count: tx.installment?.count ?? null },
});
return ok({ cancelled: res.modifiedCount });
}
// --- Okuma --- // --- Okuma ---
export type TransactionListItem = { export type TransactionListItem = {
@@ -413,6 +576,12 @@ export type TransactionListItem = {
tags: string[]; tags: string[];
createdBy: { id: string; name: string }; createdBy: { id: string; name: string };
attachmentCount: number; attachmentCount: number;
/** Mutabakat düzeltmesi (kategorisiz, düzenlenemez). */
isAdjustment: boolean;
/** Mutabakatı yapılmış hesaplar. */
reconciledAccountIds: string[];
/** Taksit: "4/9" */
installment: { planId: string; index: number; count: number } | null;
}; };
export type TransactionDetail = TransactionListItem & { export type TransactionDetail = TransactionListItem & {
@@ -429,9 +598,10 @@ export type TransactionDetail = TransactionListItem & {
updatedAt: string; updatedAt: string;
/** Düzenleme formu için ham kimlikler. */ /** Düzenleme formu için ham kimlikler. */
categoryId: string | null; categoryId: string | null;
attachments: AttachmentInfo[];
}; };
async function userNames(ids: Types.ObjectId[]): Promise<Map<string, string>> { export async function userNames(ids: Types.ObjectId[]): Promise<Map<string, string>> {
const unique = [...new Set(ids.map(String))].map((id) => new Types.ObjectId(id)); const unique = [...new Set(ids.map(String))].map((id) => new Types.ObjectId(id));
const users = await getDb() const users = await getDb()
.collection("user") .collection("user")
@@ -440,13 +610,13 @@ async function userNames(ids: Types.ObjectId[]): Promise<Map<string, string>> {
return new Map(users.map((u) => [String(u._id), String(u.name ?? "—")])); return new Map(users.map((u) => [String(u._id), String(u.name ?? "—")]));
} }
type LeanTx = Awaited<ReturnType<typeof TransactionModel.find>>[number] & { export type LeanTx = Awaited<ReturnType<typeof TransactionModel.find>>[number] & {
_id: Types.ObjectId; _id: Types.ObjectId;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}; };
function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): TransactionListItem { export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): TransactionListItem {
const acc = (id: Types.ObjectId | null | undefined) => { const acc = (id: Types.ObjectId | null | undefined) => {
if (!id) return null; if (!id) return null;
const a = ctx.accountMap.get(String(id)); const a = ctx.accountMap.get(String(id));
@@ -470,6 +640,15 @@ function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): TransactionLi
tags: tx.tags ?? [], tags: tx.tags ?? [],
createdBy: { id: String(tx.createdBy), name: names.get(String(tx.createdBy)) ?? "—" }, createdBy: { id: String(tx.createdBy), name: names.get(String(tx.createdBy)) ?? "—" },
attachmentCount: tx.attachmentCount ?? 0, attachmentCount: tx.attachmentCount ?? 0,
isAdjustment: Boolean(tx.isAdjustment),
reconciledAccountIds: (tx.reconciledAccountIds ?? []).map(String),
installment: tx.installment?.planId
? {
planId: String(tx.installment.planId),
index: tx.installment.index ?? 0,
count: tx.installment.count ?? 0,
}
: null,
}; };
} }
@@ -487,7 +666,8 @@ function filterQuery(ctx: Ctx, f: TransactionFilters): Record<string, unknown> {
}; };
} }
if (f.type) q.type = f.type; if (f.type) q.type = f.type;
if (f.durum) q.status = f.durum; // Planlı kayıtlar (12 ay ileriye kadar) yalnızca açıkça istenince listelenir (/planli sayfası).
q.status = f.durum ?? { $ne: "planned" };
if (f.hesap) { if (f.hesap) {
const id = toObjectId(f.hesap); const id = toObjectId(f.hesap);
// Gizli hesap filtresi sonuç vermez (baseFilter zaten dışlar). // Gizli hesap filtresi sonuç vermez (baseFilter zaten dışlar).
@@ -535,7 +715,7 @@ export async function listTransactions(
{ {
$match: { $match: {
...query, ...query,
status: query.status ?? "posted", status: filters.durum ?? "posted",
type: query.type ?? { $in: ["income", "expense"] }, type: query.type ?? { $in: ["income", "expense"] },
}, },
}, },
@@ -571,8 +751,14 @@ export async function getTransaction(
if (!ctx) return null; if (!ctx) return null;
const tx = (await loadOwnTransaction(ctx, transactionId)) as unknown as LeanTx | null; const tx = (await loadOwnTransaction(ctx, transactionId)) as unknown as LeanTx | null;
if (!tx) return null; if (!tx) return null;
const names = await userNames([tx.createdBy as Types.ObjectId, tx.updatedBy as Types.ObjectId]); const [names, attachments] = await Promise.all([
userNames([tx.createdBy as Types.ObjectId, tx.updatedBy as Types.ObjectId]),
tx.attachmentCount
? listOwnerAttachments(ctx.m.householdId, "transaction", tx._id)
: Promise.resolve([]),
]);
return { return {
attachments,
...toItem(ctx, tx, names), ...toItem(ctx, tx, names),
categoryId: tx.categoryId ? String(tx.categoryId) : null, categoryId: tx.categoryId ? String(tx.categoryId) : null,
splits: (tx.splits ?? []).map((s) => { splits: (tx.splits ?? []).map((s) => {
@@ -650,7 +836,7 @@ export async function exportTransactions(
} else { } else {
out.push({ out.push({
...common, ...common,
category: categoryPath(r.categoryId), category: r.isAdjustment ? t.adjustment : categoryPath(r.categoryId),
note: "", note: "",
amountMinor: sign(r.type) * r.amountMinor, amountMinor: sign(r.type) * r.amountMinor,
}); });
@@ -0,0 +1,245 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { listAccounts } from "@/features/accounts/service";
import { AccountModel } from "@/models/account";
import { AuditLogModel } from "@/models/audit-log";
import { CategoryModel } from "@/models/category";
import { ReconciliationModel } from "@/models/reconciliation";
import { TransactionModel } from "@/models/transaction";
import { todayIstanbul } from "@/shared/dates";
import { reconcileSchema } from "@/shared/schemas/reconciliation";
import type { TransactionForm } from "@/shared/schemas/transaction";
import { shiftMonth } from "@/shared/statement";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
createTransaction,
getTransaction,
setTransactionDeleted,
updateTransaction,
} from "./service";
import {
currentMonth,
getAccountStatement,
previewReconciliation,
reconcileAccount,
} from "./statement";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let emre = "";
let zeynep = "";
let fatma = "";
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
emre = ids.user("emre");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
});
afterAll(teardownDb);
const acc = (name: string) =>
AccountModel.findOne({ householdId: cakir, name })
.lean()
.then((a) => String(a!._id));
const cat = (key: string) =>
CategoryModel.findOne({ householdId: cakir, defaultKey: key })
.lean()
.then((c) => String(c!._id));
const input = (statementDate: string, statementBalance: string, acceptAdjustment = false) =>
reconcileSchema.parse({ statementDate, statementBalance, acceptAdjustment });
const tl = (minor: number) => (minor / 100).toFixed(2).replace(".", ",");
async function balanceOf(userId: string, accountId: string) {
const list = await listAccounts(userId, cakir);
return list!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
}
describe("hesap ekstresi", () => {
it("yürüyen bakiye tutarlı; ay sonu = sonraki ay başı; güncel bakiye hesap listesiyle aynı", async () => {
const ziraat = await acc("Ziraat Maaş");
const month = shiftMonth(currentMonth(), -1);
const s = (await getAccountStatement(ayse, cakir, ziraat, month))!;
expect(s.rows.length).toBeGreaterThan(0);
// Yeniden eskiye: ilk satırın bakiyesi ay sonu bakiyesi.
expect(s.rows[0]!.balanceMinor).toBe(s.closingMinor);
expect(s.closingMinor).toBe(s.openingMinor + s.inflowMinor - s.outflowMinor);
const asc = [...s.rows].reverse();
let running = s.openingMinor;
for (const r of asc) {
running += r.deltaMinor;
expect(r.balanceMinor).toBe(running);
}
const next = (await getAccountStatement(ayse, cakir, ziraat, currentMonth()))!;
expect(next.openingMinor).toBe(s.closingMinor);
expect(next.nextMonth).toBeNull();
expect(s.currentBalanceMinor).toBe(await balanceOf(ayse, ziraat));
});
it("gizli hesabın ekstresi yalnızca sahibine; hane dışına kapalı", async () => {
const pluxee = await acc("Ayşe — Pluxee");
expect(await getAccountStatement(ayse, cakir, pluxee, currentMonth())).not.toBeNull();
expect(await getAccountStatement(emre, cakir, pluxee, currentMonth())).toBeNull();
expect(await getAccountStatement(fatma, cakir, await acc("Cüzdan"), currentMonth())).toBeNull();
});
it("gizli hesaba transfer ekstrede görünür ama karşı hesap adı gizlenir", async () => {
const ziraat = await acc("Ziraat Maaş");
const pluxee = await acc("Ayşe — Pluxee");
const r = await createTransaction(ayse, cakir, {
type: "transfer",
amount: 123_45,
date: todayIstanbul(),
accountId: ziraat,
toAccountId: pluxee,
categoryId: null,
splits: [],
payee: "",
description: "",
tags: [],
attachmentIds: [],
installments: 1,
} satisfies TransactionForm);
expect(r.ok).toBe(true);
const s = (await getAccountStatement(emre, cakir, ziraat, currentMonth()))!;
const row = s.rows.find((x) => x.item.id === (r.ok ? r.data.id : ""))!;
expect(row.restricted).toBe(true);
expect(row.deltaMinor).toBe(-123_45);
expect(row.item.toAccount!.name).not.toContain("Pluxee");
expect(s.currentBalanceMinor).toBe(await balanceOf(emre, ziraat));
});
});
describe("mutabakat", () => {
it("bakiye tutuyorsa işlemler işaretlenir, düzeltme oluşmaz", async () => {
const cuzdan = await acc("Cüzdan");
const today = todayIstanbul();
const balance = await balanceOf(ayse, cuzdan);
const preview = await previewReconciliation(ayse, cakir, cuzdan, input(today, tl(balance)));
expect(preview).toMatchObject({
ok: true,
data: { differenceMinor: 0, appBalanceMinor: balance },
});
const unreconciled = preview.ok ? preview.data.unreconciledCount : 0;
expect(unreconciled).toBeGreaterThan(0);
const r = await reconcileAccount(ayse, cakir, cuzdan, input(today, tl(balance)));
expect(r).toMatchObject({ ok: true, data: { adjustmentId: null, markedCount: unreconciled } });
const again = await reconcileAccount(ayse, cakir, cuzdan, input(today, tl(balance)));
expect(again).toMatchObject({ ok: true, data: { markedCount: 0 } });
const s = (await getAccountStatement(ayse, cakir, cuzdan, currentMonth()))!;
expect(s.rows.every((row) => row.reconciled)).toBe(true);
expect(s.lastReconciliation).toMatchObject({ date: today, differenceMinor: 0 });
});
it("fark varsa onay ister; onaylanınca düzeltme işlemi oluşur ve bakiye ekstreyle eşitlenir", async () => {
const ziraat = await acc("Ziraat Maaş");
const today = todayIstanbul();
const balance = await balanceOf(ayse, ziraat);
const target = balance - 250_00;
const refused = await reconcileAccount(ayse, cakir, ziraat, input(today, tl(target)));
expect(refused.ok).toBe(false);
expect(await ReconciliationModel.countDocuments({ accountId: ziraat })).toBe(0);
const r = await reconcileAccount(ayse, cakir, ziraat, input(today, tl(target), true));
expect(r.ok).toBe(true);
const adjustmentId = r.ok ? r.data.adjustmentId! : "";
const adj = (await TransactionModel.findById(adjustmentId).lean())!;
expect(adj).toMatchObject({ type: "expense", amountMinor: 250_00, isAdjustment: true });
expect(await balanceOf(ayse, ziraat)).toBe(target);
const audit = await AuditLogModel.findOne({
action: "account.reconcile",
targetId: ziraat,
}).lean();
expect(audit!.diff).toMatchObject({ differenceMinor: -250_00 });
// Düzeltme işlemi düzenlenemez, silinebilir.
const detail = (await getTransaction(ayse, cakir, adjustmentId))!;
expect(detail.isAdjustment).toBe(true);
const edit = await updateTransaction(ayse, cakir, adjustmentId, {
type: "expense",
amount: 1_00,
date: today,
accountId: ziraat,
toAccountId: null,
categoryId: await cat("market"),
splits: [],
payee: "",
description: "",
tags: [],
attachmentIds: [],
installments: 1,
});
expect(edit.ok).toBe(false);
expect((await setTransactionDeleted(ayse, cakir, adjustmentId, true)).ok).toBe(true);
});
it("kredi kartında borç pozitif girilir, uygulamada eksi bakiyeyle karşılaştırılır", async () => {
const kart = await acc("Garanti Bonus");
const balance = await balanceOf(ayse, kart);
expect(balance).toBeLessThan(0);
const preview = await previewReconciliation(
ayse,
cakir,
kart,
input(todayIstanbul(), tl(-balance)),
);
expect(preview).toMatchObject({
ok: true,
data: { statementBalanceMinor: balance, differenceMinor: 0 },
});
});
it("işaretli işlemin tutarı değişince işaret kalkar; yalnızca açıklama değişince kalır", async () => {
const cuzdan = await acc("Cüzdan");
const tx = await TransactionModel.findOne({
accountId: cuzdan,
type: "expense",
reconciledAccountIds: cuzdan,
splits: { $size: 0 },
deletedAt: null,
}).lean();
const d = (await getTransaction(ayse, cakir, String(tx!._id)))!;
const base: TransactionForm = {
type: "expense",
amount: d.amountMinor,
date: d.date,
accountId: cuzdan,
toAccountId: null,
categoryId: d.categoryId,
splits: [],
payee: d.payeeName,
description: "yeni açıklama",
tags: d.tags,
attachmentIds: [],
installments: 1,
};
expect((await updateTransaction(ayse, cakir, d.id, base)).ok).toBe(true);
expect(
(await TransactionModel.findById(d.id).lean())!.reconciledAccountIds.map(String),
).toEqual([cuzdan]);
expect(
(await updateTransaction(ayse, cakir, d.id, { ...base, amount: d.amountMinor + 1 })).ok,
).toBe(true);
expect((await TransactionModel.findById(d.id).lean())!.reconciledAccountIds).toEqual([]);
});
it("yetki ve doğrulama: izleyici, gizli hesap, gelecek tarih", async () => {
const cuzdan = await acc("Cüzdan");
expect((await reconcileAccount(zeynep, cakir, cuzdan, input(todayIstanbul(), "0"))).ok).toBe(
false,
);
expect(
(await reconcileAccount(emre, cakir, await acc("Ayşe — Pluxee"), input(todayIstanbul(), "0")))
.ok,
).toBe(false);
expect((await previewReconciliation(ayse, cakir, cuzdan, input("2999-01-01", "0"))).ok).toBe(
false,
);
});
});
+455
View File
@@ -0,0 +1,455 @@
import "server-only";
import mongoose, { Types, type ClientSession, type PipelineStage } from "mongoose";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { writeAudit } from "@/lib/audit";
import { toObjectId } from "@/lib/object-id";
import { AccountModel } from "@/models/account";
import { ReconciliationModel } from "@/models/reconciliation";
import { TransactionModel } from "@/models/transaction";
import { isLiabilityType, type AccountType } from "@/shared/accounts";
import type { IconKey, Swatch } from "@/shared/appearance";
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
import { formatMoney } from "@/shared/format";
import { hasHouseholdRole } from "@/features/households/roles";
import type { ReconcileForm } from "@/shared/schemas/reconciliation";
import { foldTr } from "@/shared/search";
import { accountEffect, monthRange, shiftMonth, withRunningBalance } from "@/shared/statement";
import {
loadContext,
toItem,
userNames,
type Ctx,
type LeanTx,
type TransactionListItem,
} from "./service";
/**
* Hesap ekstresi (aylık, yürüyen bakiye) ve mutabakat (Bölüm 6.1).
* Ekstre yalnızca bakiyesini görebildiği hesaplar için açılır (gizli hesap → yalnızca sahibi).
* Başka birinin gizli hesabına yapılan transferler bakiyeyi etkilediği için ekstrede görünür,
* ama karşı hesap adı gizlenir ve ayrıntı açılmaz.
*/
const t = tr.statement;
const DAY_MS = 24 * 60 * 60 * 1000;
const MAX_ROWS = 2000;
/** Hesap bazında bakiyeyi etkileyen işlemler (posted, silinmemiş). */
export function accountMatch(householdId: Types.ObjectId, accountId: Types.ObjectId) {
return {
householdId,
status: "posted" as const,
deletedAt: null,
$or: [{ accountId }, { toAccountId: accountId }],
};
}
/** Açılış bakiyesi + (isteğe bağlı tarihten önceki) işlem etkileri. */
export async function balanceBefore(
householdId: Types.ObjectId,
account: { _id: Types.ObjectId; openingBalanceMinor: number },
before: Date | null,
session?: ClientSession,
): Promise<number> {
const pipeline: PipelineStage[] = [
{
$match: {
...accountMatch(householdId, account._id),
...(before ? { date: { $lt: before } } : {}),
},
},
{
$group: {
_id: null,
total: {
$sum: {
$cond: [
{ $eq: ["$type", "transfer"] },
{
$cond: [
{ $eq: ["$accountId", account._id] },
{ $multiply: ["$amountMinor", -1] },
"$amountMinor",
],
},
{
$cond: [
{ $eq: ["$type", "income"] },
"$amountMinor",
{ $multiply: ["$amountMinor", -1] },
],
},
],
},
},
},
},
];
const agg = TransactionModel.aggregate<{ total: number }>(pipeline);
if (session) agg.session(session);
const [row] = await agg;
return account.openingBalanceMinor + (row?.total ?? 0);
}
type VisibleAccount = {
_id: Types.ObjectId;
name: string;
type: AccountType;
icon: string;
color: string;
openingBalanceMinor: number;
archivedAt?: Date | null;
};
async function loadVisibleAccount(
ctx: Ctx,
accountId: string,
session?: ClientSession,
): Promise<VisibleAccount | null> {
const id = toObjectId(accountId);
const info = id ? ctx.accountMap.get(String(id)) : undefined;
if (!id || !info || info.hidden) return null;
return AccountModel.findOne({ _id: id, householdId: ctx.m.householdId }, null, { session })
.select({ name: 1, type: 1, icon: 1, color: 1, openingBalanceMinor: 1, archivedAt: 1 })
.lean<VisibleAccount>();
}
// --- Ekstre ---
export type StatementRow = {
item: TransactionListItem;
deltaMinor: number;
balanceMinor: number;
reconciled: boolean;
/** Karşı hesap başkasının gizli hesabıysa ayrıntı açılmaz. */
restricted: boolean;
};
export type LastReconciliation = {
date: string;
statementBalanceMinor: number;
differenceMinor: number;
createdAt: string;
createdBy: string;
};
export type AccountStatement = {
account: {
id: string;
name: string;
type: AccountType;
icon: IconKey;
color: Swatch;
archived: boolean;
isLiability: boolean;
};
month: string;
prevMonth: string;
nextMonth: string | null;
openingMinor: number;
closingMinor: number;
inflowMinor: number;
outflowMinor: number;
currentBalanceMinor: number;
/** Yeniden eskiye. */
rows: StatementRow[];
truncated: boolean;
lastReconciliation: LastReconciliation | null;
canReconcile: boolean;
};
export function currentMonth(now = new Date()): string {
return todayIstanbul(now).slice(0, 7);
}
export async function getAccountStatement(
userId: string,
householdId: string,
accountId: string,
month: string,
): Promise<AccountStatement | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const account = await loadVisibleAccount(ctx, accountId);
if (!account) return null;
const range = monthRange(month);
const start = istanbulDayStart(range.start)!;
const end = istanbulDayStart(range.end)!;
const hh = ctx.m.householdId;
const [openingMinor, currentBalanceMinor, txs, last] = await Promise.all([
balanceBefore(hh, account, start),
balanceBefore(hh, account, null),
TransactionModel.find({ ...accountMatch(hh, account._id), date: { $gte: start, $lt: end } })
.sort({ date: 1, createdAt: 1 })
.limit(MAX_ROWS + 1)
.lean() as unknown as Promise<LeanTx[]>,
ReconciliationModel.findOne({ householdId: hh, accountId: account._id })
.sort({ statementDate: -1, createdAt: -1 })
.lean(),
]);
const truncated = txs.length > MAX_ROWS;
const rowsAsc = txs.slice(0, MAX_ROWS);
const names = await userNames([
...rowsAsc.map((r) => r.createdBy as Types.ObjectId),
...(last ? [last.createdBy] : []),
]);
const accountKey = String(account._id);
const effects = rowsAsc.map((tx) => {
const item = toItem(ctx, tx, names);
const counterpart = tx.type === "transfer" ? [item.account, item.toAccount] : [];
const restricted = counterpart.some((a) => a && ctx.accountMap.get(a.id)?.hidden);
if (restricted) {
// Gizli karşı hesabın adı sızmasın.
for (const a of counterpart) {
if (a && ctx.accountMap.get(a.id)?.hidden) a.name = tr.transactions.list.hiddenAccount;
}
}
return {
item,
restricted,
reconciled: item.reconciledAccountIds.includes(accountKey),
deltaMinor: accountEffect(
{
type: tx.type,
amountMinor: tx.amountMinor,
accountId: String(tx.accountId),
toAccountId: tx.toAccountId ? String(tx.toAccountId) : null,
},
accountKey,
),
};
});
const running = withRunningBalance(openingMinor, effects);
const inflowMinor = effects.filter((e) => e.deltaMinor > 0).reduce((a, e) => a + e.deltaMinor, 0);
const outflowMinor = effects
.filter((e) => e.deltaMinor < 0)
.reduce((a, e) => a - e.deltaMinor, 0);
const now = currentMonth();
return {
account: {
id: accountKey,
name: account.name,
type: account.type,
icon: account.icon as IconKey,
color: account.color as Swatch,
archived: Boolean(account.archivedAt),
isLiability: isLiabilityType(account.type),
},
month,
prevMonth: shiftMonth(month, -1),
nextMonth: month < now ? shiftMonth(month, 1) : null,
openingMinor,
closingMinor: running.at(-1)?.balanceMinor ?? openingMinor,
inflowMinor,
outflowMinor,
currentBalanceMinor,
rows: running.reverse(),
truncated,
lastReconciliation: last
? {
date: toIstanbulYmd(last.statementDate),
statementBalanceMinor: last.statementBalanceMinor,
differenceMinor: last.differenceMinor,
createdAt: new Date(last.createdAt).toISOString(),
createdBy: names.get(String(last.createdBy)) ?? "—",
}
: null,
canReconcile: hasHouseholdRole(ctx.m.role, "member") && !account.archivedAt,
};
}
// --- Mutabakat ---
export type ReconcilePreview = {
statementDate: string;
/** Uygulamadaki işaretle (kredi kartında eksi). */
statementBalanceMinor: number;
appBalanceMinor: number;
/** ekstre − uygulama: + ise uygulamada eksik gelir, − ise eksik gider. */
differenceMinor: number;
/** O tarihe kadar henüz işaretlenmemiş işlem sayısı. */
unreconciledCount: number;
};
function validateDate(ymd: string): Date | null {
const start = istanbulDayStart(ymd);
if (!start || ymd > todayIstanbul()) return null;
return start;
}
async function computePreview(
ctx: Ctx,
account: VisibleAccount,
input: ReconcileForm,
session?: ClientSession,
): Promise<ActionResult<ReconcilePreview & { endExclusive: Date; dayStart: Date }>> {
const dayStart = validateDate(input.statementDate);
if (!dayStart) return fail(t.errors.date, { statementDate: t.errors.date });
const endExclusive = new Date(dayStart.getTime() + DAY_MS);
// Kredi kartında kullanıcı borcu pozitif girer; uygulamada borç eksi bakiyedir.
const statementBalanceMinor = isLiabilityType(account.type)
? -input.statementBalance
: input.statementBalance;
const appBalanceMinor = await balanceBefore(ctx.m.householdId, account, endExclusive, session);
const unreconciledCount = await TransactionModel.countDocuments(
{
...accountMatch(ctx.m.householdId, account._id),
date: { $lt: endExclusive },
reconciledAccountIds: { $ne: account._id },
},
{ session },
);
return ok({
statementDate: input.statementDate,
statementBalanceMinor,
appBalanceMinor,
differenceMinor: statementBalanceMinor - appBalanceMinor,
unreconciledCount,
endExclusive,
dayStart,
});
}
export async function previewReconciliation(
userId: string,
householdId: string,
accountId: string,
input: ReconcileForm,
): Promise<ActionResult<ReconcilePreview>> {
const ctx = await loadContext(userId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const account = await loadVisibleAccount(ctx, accountId);
if (!account) return fail(tr.common.notFound);
const result = await computePreview(ctx, account, input);
if (!result.ok) return result;
const { endExclusive: _e, dayStart: _d, ...preview } = result.data;
return ok(preview);
}
class ReconcileAbort extends Error {
constructor(readonly result: ActionResult<never>) {
super("abort");
}
}
/**
* Mutabakatı uygular (tek transaction): fark varsa (ve onaylandıysa) düzeltme işlemi oluşturur,
* ekstre tarihine kadarki işlemleri bu hesap için işaretler, kaydı ve denetim kaydını yazar.
*/
export async function reconcileAccount(
actorId: string,
householdId: string,
accountId: string,
input: ReconcileForm,
): Promise<
ActionResult<{ adjustmentId: string | null; markedCount: number; differenceMinor: number }>
> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const pre = await loadVisibleAccount(ctx, accountId);
if (!pre) return fail(tr.common.notFound);
if (pre.archivedAt) return fail(t.errors.archived);
const actor = new Types.ObjectId(actorId);
const hh = ctx.m.householdId;
try {
let out = { adjustmentId: null as string | null, markedCount: 0, differenceMinor: 0 };
await mongoose.connection.transaction(async (session) => {
const account = (await loadVisibleAccount(ctx, accountId, session))!;
const preview = await computePreview(ctx, account, input, session);
if (!preview.ok) throw new ReconcileAbort(preview);
const p = preview.data;
if (p.differenceMinor !== 0 && !input.acceptAdjustment) {
throw new ReconcileAbort(fail(t.errors.needsAdjustment));
}
let adjustmentId: Types.ObjectId | null = null;
if (p.differenceMinor !== 0) {
const description = t.adjustmentDescription(formatMoney(p.statementBalanceMinor));
const [adj] = await TransactionModel.create(
[
{
householdId: hh,
type: p.differenceMinor > 0 ? "income" : "expense",
status: "posted",
date: p.dayStart,
accountId: account._id,
amountMinor: Math.abs(p.differenceMinor),
categoryId: null,
splits: [],
effectiveCategoryIds: [],
payeeName: "",
description,
searchText: foldTr(`${tr.transactions.adjustment} ${description}`),
isAdjustment: true,
reconciledAccountIds: [account._id],
createdBy: actor,
updatedBy: actor,
},
],
{ session },
);
adjustmentId = adj!._id;
}
const marked = await TransactionModel.updateMany(
{
...accountMatch(hh, account._id),
date: { $lt: p.endExclusive },
reconciledAccountIds: { $ne: account._id },
},
{ $addToSet: { reconciledAccountIds: account._id } },
{ session },
);
await ReconciliationModel.create(
[
{
householdId: hh,
accountId: account._id,
statementDate: p.dayStart,
statementBalanceMinor: p.statementBalanceMinor,
appBalanceMinor: p.appBalanceMinor,
differenceMinor: p.differenceMinor,
adjustmentTransactionId: adjustmentId,
markedCount: marked.modifiedCount,
createdBy: actor,
},
],
{ session },
);
await writeAudit(
{
action: "account.reconcile",
actorId: actor,
householdId: hh,
targetType: "account",
targetId: String(account._id),
diff: {
statementDate: p.statementDate,
statementBalanceMinor: p.statementBalanceMinor,
appBalanceMinor: p.appBalanceMinor,
differenceMinor: p.differenceMinor,
adjustmentTransactionId: adjustmentId ? String(adjustmentId) : null,
markedCount: marked.modifiedCount,
},
},
session,
);
out = {
adjustmentId: adjustmentId ? String(adjustmentId) : null,
markedCount: marked.modifiedCount,
differenceMinor: p.differenceMinor,
};
});
return ok(out);
} catch (error) {
if (error instanceof ReconcileAbort) return error.result;
throw error;
}
}
+227 -1
View File
@@ -199,6 +199,9 @@ export const tr = {
cardDueValueDay: "Ayın günü", cardDueValueDay: "Ayın günü",
cardPaymentAccount: "Ödeme hesabı", cardPaymentAccount: "Ödeme hesabı",
cardPaymentAccountNone: "— Seçilmedi —", cardPaymentAccountNone: "— Seçilmedi —",
cardMinimumRate: "Asgari ödeme oranı (%)",
cardMinimumRateHint:
"Boş bırakılırsa limit 50.000 TL'nin altındaysa %20, üstündeyse %40 kullanılır.",
}, },
summary: { summary: {
assets: "Varlıklar", assets: "Varlıklar",
@@ -256,7 +259,7 @@ export const tr = {
description: "Açıklama", description: "Açıklama",
tags: "Etiketler", tags: "Etiketler",
tagsHint: "Virgülle ayırın (ör. tatil, indirim)", tagsHint: "Virgülle ayırın (ör. tatil, indirim)",
advanced: "Gelişmiş: etiket, bölme", advanced: "Gelişmiş: ek, etiket, bölme",
split: "Bölünmüş işlem", split: "Bölünmüş işlem",
splitHint: "Tutarı birden çok kategoriye bölün; kalemlerin toplamı tutara eşit olmalı.", splitHint: "Tutarı birden çok kategoriye bölün; kalemlerin toplamı tutara eşit olmalı.",
addSplit: "Kalem ekle", addSplit: "Kalem ekle",
@@ -266,6 +269,11 @@ export const tr = {
remainingOk: "Kalemler toplamı tutara eşit", remainingOk: "Kalemler toplamı tutara eşit",
fillRemaining: "Kalanı son kaleme ekle", fillRemaining: "Kalanı son kaleme ekle",
suggested: "Bu satıcı için son kullanılan kategori ve hesap önerildi.", suggested: "Bu satıcı için son kullanılan kategori ve hesap önerildi.",
installments: "Taksit",
singlePayment: "Peşin",
installmentCount: (n: number) => `${n} taksit`,
installmentHint: (n: number, each: string) =>
`${n} × yaklaşık ${each}; vadesi gelen taksit kendi döneminde otomatik gerçekleşir.`,
}, },
validation: { validation: {
amountPositive: "Tutar sıfırdan büyük olmalı", amountPositive: "Tutar sıfırdan büyük olmalı",
@@ -277,6 +285,9 @@ export const tr = {
splitSum: "Kalemlerin toplamı tutara eşit olmalı", splitSum: "Kalemlerin toplamı tutara eşit olmalı",
accountInvalid: "Hesap bu hanede yok, arşivli veya size kapalı.", accountInvalid: "Hesap bu hanede yok, arşivli veya size kapalı.",
categoryInvalid: "Kategori bu hanede yok, arşivli veya işlem türüyle uyuşmuyor.", categoryInvalid: "Kategori bu hanede yok, arşivli veya işlem türüyle uyuşmuyor.",
installmentsMax: "En fazla 36 taksit",
installmentsExpenseOnly: "Taksit yalnızca bölünmemiş kredi kartı giderinde kullanılabilir.",
installmentsCardOnly: "Taksit yalnızca kredi kartı hesabında kullanılabilir.",
}, },
list: { list: {
empty: "Bu filtrelerle işlem bulunamadı.", empty: "Bu filtrelerle işlem bulunamadı.",
@@ -321,6 +332,7 @@ export const tr = {
updated: "İşlem güncellendi.", updated: "İşlem güncellendi.",
deleted: "İşlem silindi.", deleted: "İşlem silindi.",
restored: "İşlem geri alındı.", restored: "İşlem geri alındı.",
installmentsCancelled: (n: number) => `${n} kalan taksit iptal edildi.`,
}, },
deleteTitle: "İşlem silinsin mi?", deleteTitle: "İşlem silinsin mi?",
deleteDescription: deleteDescription:
@@ -344,10 +356,224 @@ export const tr = {
], ],
filename: (date: string) => `butcem-islemler-${date}.csv`, filename: (date: string) => `butcem-islemler-${date}.csv`,
}, },
adjustment: "Mutabakat düzeltmesi",
adjustmentReadOnly:
"Mutabakat düzeltmesi düzenlenemez; gerekirse silip mutabakatı yeniden yapın.",
reconciled: "Mutabakatı yapıldı",
cancelInstallments: "Kalan taksitleri iptal et",
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.", readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
noAccounts: "İşlem girmek için önce bir hesap gerekli.", noAccounts: "İşlem girmek için önce bir hesap gerekli.",
noAccountsLink: "Hesaplara git", noAccountsLink: "Hesaplara git",
}, },
recurring: {
title: "Planlı işlemler",
description:
"Düzenli ve planlı gelir/giderler. Planlı kayıtlar bakiyeyi etkilemez; onaylanınca gerçekleşir.",
newRule: "Yeni düzenli işlem",
createTitle: "Yeni düzenli işlem",
editTitle: "Düzenli işlemi düzenle",
tabs: { upcoming: "Yaklaşan", overdue: "Vadesi geçmiş", rules: "Kurallar" },
fields: {
estimate: "Tutar tahmini (ör. fatura); onaylarken düzeltilir",
startDate: "İlk tarih",
frequency: "Sıklık",
interval: "Kaç birimde bir",
monthlyMode: "Ayın hangi günü",
end: "Bitiş",
count: "Tekrar sayısı",
endDate: "Bitiş tarihi",
preview: "İlk tarihler",
},
frequencies: { daily: "Günlük", weekly: "Haftalık", monthly: "Aylık", yearly: "Yıllık" },
units: { daily: "gün", weekly: "hafta", monthly: "ay", yearly: "yıl" },
monthlyModes: { day: "İlk tarihin günü", last: "Ayın son günü" },
endKinds: { never: "Bitiş yok", count: "Tekrar sayısı", date: "Bitiş tarihi" },
actions: {
confirm: "Onayla",
skip: "Atla",
postpone: "Ertele",
edit: "Düzenle",
delete: "Sil",
more: "Diğer işlemler",
},
confirmTitle: "İşlemi onayla",
confirmDescription: "Gerçekleşen tutarı ve tarihi kontrol edin; onaylanınca bakiyeye yansır.",
postponeTitle: "Ertele",
postponeDate: "Yeni tarih",
scopeTitle: "Hangi kayıtlar değişsin?",
scopes: { one: "Yalnızca bu", following: "Bu ve sonrakiler", all: "Tümü" },
scopeHints: {
one: "Yalnızca bu planlı kayıt değişir; seri aynı kalır.",
following: "Seri bu tarihten itibaren yeni değerlerle devam eder.",
all: "Onaylanmamış tüm planlı kayıtlar yeni değerlerle yeniden oluşturulur. Gerçekleşmiş kayıtlar değişmez.",
},
deleteTitle: "Düzenli işlem silinsin mi?",
deleteDescription:
"Gerçekleşmiş kayıtlar korunur; yalnızca onaylanmamış planlı kayıtlar silinir.",
deleteScopes: { following: "Bu ve sonrakileri sil", all: "Tüm seriyi sil" },
estimate: "Tahmini",
next: (date: string) => `Sıradaki: ${date}`,
remaining: (n: number) => `${n} tekrar kaldı`,
endless: "Süresiz",
series: (pos: string) => `${pos}`,
empty: {
upcoming: "Önümüzdeki 60 günde planlı işlem yok.",
overdue: "Vadesi geçmiş planlı işlem yok.",
rules: "Henüz düzenli işlem yok. Kira, faturalar ve abonelikler için ekleyin.",
},
overdueBanner: (n: number) =>
`${n} planlı işlemin tarihi geçti; onaylayın, erteleyin veya atlayın.`,
overdueLink: "Planlı işlemlere git",
upcomingTitle: "Yaklaşan planlı işlemler",
toast: {
created: "Düzenli işlem kaydedildi.",
updated: "Düzenli işlem güncellendi.",
deleted: "Düzenli işlem silindi.",
confirmed: "İşlem onaylandı.",
skipped: "Bu tekrar atlandı.",
postponed: "İşlem ertelendi.",
},
validation: {
interval: "1 ile 12 arasında olmalı",
count: "1 ile 600 arasında bir sayı girin",
endBeforeStart: "Bitiş tarihi ilk tarihten önce olamaz",
},
errors: {
splitAmount: "Bölünmüş planlı işlemin tutarı değiştirilemez.",
postponeDate: "Yeni tarih mevcut tarihten sonra olmalı.",
},
},
attachments: {
title: "Ekler",
add: "Ek ekle",
addHint: { image: "Fiş veya fatura fotoğrafı", imagePdf: "Görsel veya PDF (ör. bordro)" },
count: (n: number) => `${n} ek`,
uploading: "Yükleniyor…",
remove: "Eki kaldır",
open: "Aç",
download: "İndir",
fullscreen: "Tam ekran",
exitFullscreen: "Tam ekrandan çık",
previous: "Önceki",
next: "Sonraki",
position: (i: number, n: number) => `${i} / ${n}`,
viewerTitle: "Ek dosyalar",
pdf: "PDF belgesi",
moreCount: (n: number) => `+${n}`,
waitForUploads: "Yüklemeler bitmeden kaydedilemez.",
errors: {
empty: "Dosya seçilmedi veya dosya boş.",
tooLarge: (mb: number) => `Dosya çok büyük (en fazla ${mb} MB).`,
tooMany: (n: number) => `Bir kayda en fazla ${n} dosya eklenebilir.`,
tooManyPending: "Kaydedilmemiş çok fazla ek var; önce formu kaydedin.",
unsupported: "Desteklenmeyen dosya türü. JPEG, PNG, WebP, HEIC veya PDF yükleyin.",
unreadable: "Dosya okunamadı veya bozuk.",
kindNotAllowed: "Gider işlemlerine yalnızca görsel eklenebilir (PDF gelir ve transferlerde).",
invalid: "Ek dosya bulunamadı veya bu kayda eklenemez.",
upload: "Dosya yüklenemedi.",
},
},
card: {
title: "Kart özeti",
debt: "Toplam borç",
available: "Kullanılabilir limit",
limitOf: (limit: string) => `Limit ${limit}`,
pendingInstallments: (amount: string) => `Gelecek taksitler limitten düşer: ${amount}`,
statementPeriod: "Hesap kesim dönemi",
currentPeriod: "Güncel dönem",
periodRange: (start: string, end: string) => `${start} – ${end}`,
statementDebt: "Dönem borcu",
minimum: (rate: string) => `Asgari ödeme (%${rate})`,
paid: "Ödenen",
remaining: "Kalan",
dueDate: "Son ödeme",
daysLeft: (n: number) =>
n > 0 ? `${n} gün kaldı` : n === 0 ? "Bugün son gün" : `${-n} gün geçti`,
paidInFull: "Dönem borcu ödendi.",
spent: "Harcama",
pay: "Borcu öde",
payDescription: "Kart borcu ödemesi",
upcomingInstallments: "Yaklaşan taksitler",
installmentPeriod: (end: string, due: string, n: number) =>
`${end} kesim · son ödeme ${due} · ${n} taksit`,
noInstallments: "Bekleyen taksit yok.",
planned: "Planlı harcamalar",
history: "Geçmiş ekstreler",
historyHeaders: {
period: "Dönem",
debt: "Borç",
minimum: "Asgari",
paid: "Ödenen",
due: "Son ödeme",
},
},
overdraft: {
title: "Esnek hesap (KMH)",
limit: "KMH limiti",
used: "Kullanılan",
available: "Kullanılabilir KMH",
rate: (rate: string) => `Aylık faiz %${rate}`,
lastPeriod: "Geçen dönem",
currentSoFar: "Bu dönem (şimdiye kadar)",
estimatedInterest: "Tahmini faiz",
averageUsed: (amount: string) => `Ortalama kullanım ${amount}`,
bookInterest: "Faiz kaydı ekle",
booked: "Bu dönemin faiz kaydı girilmiş.",
interestDescription: (range: string) => `KMH faizi (${range})`,
taxNote: "Tahmin vergiler (KKDF/BSMV) hariçtir; banka ekstresindeki tutarı girin.",
},
statement: {
title: "Hesap ekstresi",
back: "Hesaplar",
allTransactions: "Tüm işlemler",
prevMonth: "Önceki ay",
nextMonth: "Sonraki ay",
opening: "Ay başı bakiye",
inflow: "Giriş",
outflow: "Çıkış",
closing: "Ay sonu bakiye",
current: "Güncel bakiye",
balanceAfter: "Bakiye",
empty: "Bu ayda işlem yok.",
truncated: "Çok fazla işlem var; yalnızca ilk 2.000 işlem gösteriliyor.",
lastReconciliation: (date: string, balance: string) =>
`Son mutabakat: ${date} · ekstre bakiyesi ${balance}`,
noReconciliation: "Henüz mutabakat yapılmadı.",
reconciledHint: "Mutabakatı yapılmış işlem",
adjustmentDescription: (statementBalance: string) =>
`Ekstre bakiyesi ${statementBalance} ile fark`,
reconcile: {
open: "Mutabakat yap",
title: "Mutabakat",
description:
"Banka veya kart ekstrenizdeki tarihi ve bakiyeyi girin; uygulamadaki bakiye ile karşılaştırılır.",
date: "Ekstre tarihi",
balance: "Ekstredeki bakiye (TL)",
debt: "Ekstredeki borç (TL)",
balanceHint: "Eksi bakiye için başına - koyun (ör. KMH kullanımı).",
debtHint: "Karttaki toplam borcu pozitif girin.",
check: "Karşılaştır",
statementBalance: "Ekstre",
appBalance: "Uygulama",
difference: "Fark",
matches: "Bakiyeler tutuyor.",
mismatch: "Bakiyeler tutmuyor. Eksik veya hatalı işlem olabilir.",
adjustmentOffer: (amount: string, kind: string) =>
`Farkı kapatmak için ${amount} tutarında bir “${kind}” düzeltme işlemi oluşturulsun.`,
acceptAdjustment: "Düzeltme işlemi oluştur",
unreconciled: (n: number) => `${n} işlem işaretlenecek.`,
confirm: "Mutabakatı tamamla",
confirmWithAdjustment: "Düzelt ve tamamla",
done: (n: number) => `Mutabakat tamamlandı; ${n} işlem işaretlendi.`,
doneWithAdjustment: (n: number) => `Düzeltme işlemi oluşturuldu ve ${n} işlem işaretlendi.`,
},
errors: {
date: "Geçerli ve bugünden ileri olmayan bir tarih girin.",
archived: "Arşivlenmiş hesapta mutabakat yapılamaz.",
needsAdjustment:
"Bakiyeler tutmuyor; düzeltme işlemini onaylayın veya işlemleri kontrol edin.",
},
},
categories: { categories: {
title: "Kategoriler", title: "Kategoriler",
description: "Gelir ve gider kategorileri. Alt kategoriler raporlarda üst kategoriye toplanır.", description: "Gelir ve gider kategorileri. Alt kategoriler raporlarda üst kategoriye toplanır.",
View File
+18
View File
@@ -0,0 +1,18 @@
import "server-only";
import { env } from "@/lib/env";
import { createLocalDriver } from "./local";
import type { StorageDriver } from "./types";
export type { StorageDriver } from "./types";
export { isValidStorageKey } from "./types";
/**
* Depolama soyutlaması (Bölüm 7.4): `put`, `get`, `delete`. İlk sürücü yerel disk (`UPLOAD_DIR`,
* canlıda Docker volume). İleride S3 uyumlu (MinIO) sürücü aynı arayüzle eklenebilir.
*/
const g = globalThis as unknown as { __butcemStorage?: StorageDriver };
export function getStorage(): StorageDriver {
g.__butcemStorage ??= createLocalDriver(env().UPLOAD_DIR);
return g.__butcemStorage;
}
+35
View File
@@ -0,0 +1,35 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { createLocalDriver } from "./local";
import { isValidStorageKey } from "./types";
const dirs: string[] = [];
afterAll(() => Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true }))));
describe("yerel depolama", () => {
it("anahtar biçimi: yol geçişi ve beklenmeyen uzantılar reddedilir", () => {
expect(isValidStorageKey("ab/0123456789abcdef0123.webp")).toBe(true);
expect(isValidStorageKey("ab/0123456789abcdef0123.thumb.webp")).toBe(true);
expect(isValidStorageKey("ab/0123456789abcdef0123.pdf")).toBe(true);
expect(isValidStorageKey("../0123456789abcdef0123.webp")).toBe(false);
expect(isValidStorageKey("ab/../../etc/passwd")).toBe(false);
expect(isValidStorageKey("/ab/0123456789abcdef0123.webp")).toBe(false);
expect(isValidStorageKey("ab/0123456789abcdef0123.html")).toBe(false);
});
it("put/get/delete", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "butcem-storage-"));
dirs.push(dir);
const s = createLocalDriver(dir);
const key = "cd/cdef0123456789abcdef.pdf";
expect(await s.get(key)).toBeNull();
await s.put(key, new TextEncoder().encode("merhaba"));
expect((await s.get(key))!.toString()).toBe("merhaba");
await s.delete(key);
await s.delete(key);
expect(await s.get(key)).toBeNull();
await expect(s.put("../x.pdf", new Uint8Array())).rejects.toThrow();
});
});
+37
View File
@@ -0,0 +1,37 @@
import { randomBytes } from "node:crypto";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { isValidStorageKey, type StorageDriver } from "./types";
/** Yerel disk sürücüsü. Yazma atomiktir (geçici dosya + rename); yarım dosya servis edilmez. */
export function createLocalDriver(baseDir: string): StorageDriver {
const root = path.resolve(baseDir);
function resolve(key: string): string {
if (!isValidStorageKey(key)) throw new Error(`Geçersiz depolama anahtarı: ${key}`);
const full = path.resolve(root, key);
if (!full.startsWith(root + path.sep)) throw new Error("Depolama kökü dışına çıkılamaz.");
return full;
}
return {
async put(key, data) {
const full = resolve(key);
await mkdir(path.dirname(full), { recursive: true, mode: 0o750 });
const tmp = `${full}.${randomBytes(6).toString("hex")}.tmp`;
await writeFile(tmp, data, { mode: 0o640 });
await rename(tmp, full);
},
async get(key) {
try {
return await readFile(resolve(key));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
},
async delete(key) {
await rm(resolve(key), { force: true });
},
};
}
+18
View File
@@ -0,0 +1,18 @@
export interface StorageDriver {
/** Dosyayı yazar (varsa üzerine yazar). */
put(key: string, data: Uint8Array): Promise<void>;
/** Dosyayı okur; yoksa null. */
get(key: string): Promise<Buffer | null>;
/** Dosyayı siler; yoksa sessizce geçer. */
delete(key: string): Promise<void>;
}
/**
* Anahtar biçimi: "ab/<rastgele-id>[.thumb].webp|pdf". Yalnızca küçük harf, rakam, tire, nokta
* ve tek bir alt klasör; ".." veya mutlak yol olamaz (dizin dışına çıkma engellenir).
*/
const KEY_PATTERN = /^[a-z0-9]{2}\/[a-z0-9-]{16,64}(\.thumb)?\.(webp|pdf)$/;
export function isValidStorageKey(key: string): boolean {
return KEY_PATTERN.test(key);
}
+74
View File
@@ -0,0 +1,74 @@
import "server-only";
import sharp from "sharp";
import type { DetectedType } from "@/shared/attachments";
/** Ana görsel uzun kenar sınırı (fiş okunabilir kalsın, dosya küçülsün). */
export const MAX_IMAGE_EDGE = 2000;
export const THUMB_EDGE = 320;
/** Dekompresyon bombasına karşı piksel sınırı (~50 MP). */
const MAX_INPUT_PIXELS = 50_000_000;
export type ProcessedImage = {
main: Buffer;
thumb: Buffer;
width: number;
height: number;
};
export class UnreadableImageError extends Error {}
/**
* Görseli normalize eder: EXIF yönüne göre döndürür, en fazla 2000 px'e küçültür ve WebP'ye
* çevirir. sharp çıktıya meta veri yazmaz (withMetadata kullanılmadığı için EXIF/GPS/ICC dahil
* tüm meta veri düşer). HEIC (iPhone, HEVC) sharp'ın hazır derlemesinde çözülemediği için
* libheif (WASM) ile çözülür.
*/
export async function processImage(bytes: Buffer, type: DetectedType): Promise<ProcessedImage> {
try {
const source = type === "heic" ? await decodeHeic(bytes) : sharpFrom(bytes).rotate();
const { data, info } = await source
.resize({
width: MAX_IMAGE_EDGE,
height: MAX_IMAGE_EDGE,
fit: "inside",
withoutEnlargement: true,
})
.webp({ quality: 82 })
.toBuffer({ resolveWithObject: true });
const thumb = await sharp(data)
.resize({ width: THUMB_EDGE, height: THUMB_EDGE, fit: "inside", withoutEnlargement: true })
.webp({ quality: 70 })
.toBuffer();
return { main: data, thumb, width: info.width, height: info.height };
} catch (error) {
throw new UnreadableImageError(error instanceof Error ? error.message : String(error));
}
}
function sharpFrom(bytes: Buffer) {
return sharp(bytes, { failOn: "error", limitInputPixels: MAX_INPUT_PIXELS });
}
async function decodeHeic(bytes: Buffer) {
// Bazı .heic dosyaları aslında AV1 (AVIF) içerir; sharp bunları doğrudan çözebilir.
try {
const meta = await sharpFrom(bytes).metadata();
if (meta.compression === "av1") return sharpFrom(bytes).rotate();
} catch {
// libheif ile denenecek.
}
const { default: decode } = await import("heic-decode");
const image = await decode({ buffer: bytes });
if (image.width * image.height > MAX_INPUT_PIXELS) throw new Error("Görsel çok büyük.");
// libheif yönlendirmeyi (irot/imir) çözerken uygular; ham RGBA piksellerden devam edilir.
return sharp(Buffer.from(image.data.buffer, image.data.byteOffset, image.data.byteLength), {
raw: { width: image.width, height: image.height, channels: 4 },
});
}
/** PDF'ler değiştirilmeden saklanır; yalnızca yapı kontrolü (başlık + dosya sonu işareti). */
export function isPlausiblePdf(bytes: Buffer): boolean {
if (bytes.subarray(0, 5).toString("latin1") !== "%PDF-") return false;
const tail = bytes.subarray(Math.max(0, bytes.length - 2048)).toString("latin1");
return tail.includes("%%EOF");
}
+5
View File
@@ -26,6 +26,11 @@ const creditCardSchema = new Schema(
kind: { type: String, enum: DUE_RULE_KINDS, default: "daysAfter" }, kind: { type: String, enum: DUE_RULE_KINDS, default: "daysAfter" },
value: { type: Number, min: 1, max: 31, default: 10 }, value: { type: Number, min: 1, max: 31, default: 10 },
}, },
/**
* Asgari ödeme oranı, baz puan (2000 = %20). null → varsayılan kural: limit 50.000 TL altı %20,
* üstü %40 (Faz 7 kararı).
*/
minimumPaymentRateBp: { type: Number, min: 0, max: 10_000, default: null },
/** Kart borcunun ödeneceği varsayılan hesap. */ /** Kart borcunun ödeneceği varsayılan hesap. */
paymentAccountId: { type: Schema.Types.ObjectId, default: null }, paymentAccountId: { type: Schema.Types.ObjectId, default: null },
}, },
+51
View File
@@ -0,0 +1,51 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
import { ATTACHMENT_KINDS } from "@/shared/attachments";
/** Ek dosyanın bağlanabileceği kayıt türleri (ileride: alışveriş fişi, cari hareket…). */
export const ATTACHMENT_OWNER_TYPES = ["transaction"] as const;
export type AttachmentOwnerType = (typeof ATTACHMENT_OWNER_TYPES)[number];
/**
* Ek dosya meta verisi (Bölüm 7.4). Dosyanın kendisi depolama sürücüsünde, rastgele anahtarla.
* - Yüklenince `ownerId: null` (bekleyen); kayıt kaydedilince sahibine bağlanır.
* - Bekleyen ek yalnızca yükleyene görünür ve 24 saat içinde bağlanmazsa temizlenir.
* - Kayıttan çıkarılan ek `deletedAt` alır; dosya saklama süresi sonunda silinir.
*/
const attachmentSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
ownerType: { type: String, enum: ATTACHMENT_OWNER_TYPES, default: null },
ownerId: { type: Schema.Types.ObjectId, default: null },
kind: { type: String, enum: ATTACHMENT_KINDS, required: true },
/** Saklanan içeriğin türü (görseller her zaman WebP'ye çevrilir). */
mimeType: { type: String, enum: ["image/webp", "application/pdf"], required: true },
/** Kaynak tür (jpeg, png, webp, heic, pdf) — bilgi amaçlı. */
sourceType: { type: String, required: true },
originalName: { type: String, default: "", maxlength: 120 },
size: { type: Number, required: true },
originalSize: { type: Number, required: true },
width: { type: Number, default: null },
height: { type: Number, default: null },
storageKey: { type: String, required: true },
thumbKey: { type: String, default: null },
uploadedBy: { type: Schema.Types.ObjectId, required: true },
/** Kayıt içindeki sıra (carousel sırası). */
order: { type: Number, default: 0 },
deletedAt: { type: Date, default: null },
},
{ timestamps: true, collection: "attachments" },
);
attachmentSchema.index({ householdId: 1, ownerType: 1, ownerId: 1, deletedAt: 1, order: 1 });
attachmentSchema.index({ ownerId: 1, createdAt: 1 });
attachmentSchema.index(
{ deletedAt: 1 },
{ partialFilterExpression: { deletedAt: { $type: "date" } } },
);
attachmentSchema.index({ storageKey: 1 }, { unique: true });
export type Attachment = InferSchemaType<typeof attachmentSchema>;
export const AttachmentModel: Model<Attachment> =
(models.Attachment as Model<Attachment> | undefined) ??
model<Attachment>("Attachment", attachmentSchema);
+2
View File
@@ -21,11 +21,13 @@ export const AUDIT_ACTIONS = [
"account.update", "account.update",
"account.archive", "account.archive",
"account.unarchive", "account.unarchive",
"account.reconcile",
"category.archive", "category.archive",
"category.unarchive", "category.unarchive",
"category.move", "category.move",
"transaction.delete", "transaction.delete",
"transaction.restore", "transaction.restore",
"recurring.delete",
] as const; ] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number]; export type AuditAction = (typeof AUDIT_ACTIONS)[number];
+38
View File
@@ -0,0 +1,38 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
/**
* Kart/KMH dönem kaydı (Bölüm 6.8, `statements`). Tutarlar işlemlerden hesaplanır ve kart özeti
* okunurken güncellenir (geriye dönük girilen işlem dönem borcunu doğru değiştirir); bu koleksiyon
* raporlar ve mobil API için son hesaplanan değerleri tutar.
*/
const cardStatementSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
accountId: { type: Schema.Types.ObjectId, required: true },
kind: { type: String, enum: ["credit_card", "overdraft"], required: true },
/** "YYYY-MM-DD" (İstanbul), ikisi de dahil. */
periodStart: { type: String, required: true },
periodEnd: { type: String, required: true },
dueDate: { type: String, default: null },
/** Kesim günü sonundaki borç (pozitif). */
statementDebtMinor: { type: Number, default: 0 },
minimumPaymentMinor: { type: Number, default: 0 },
/** Kesimden sonra (bir sonraki kesime kadar) yapılan ödemeler. */
paidMinor: { type: Number, default: 0 },
/** Dönemdeki harcamalar (çıkışlar). */
spentMinor: { type: Number, default: 0 },
/** KMH: tahmini faiz. */
interestMinor: { type: Number, default: null },
computedAt: { type: Date, required: true },
},
{ timestamps: true, collection: "statements" },
);
cardStatementSchema.index({ accountId: 1, periodEnd: 1 }, { unique: true });
cardStatementSchema.index({ householdId: 1, accountId: 1, periodEnd: -1 });
export type CardStatement = InferSchemaType<typeof cardStatementSchema>;
export const CardStatementModel: Model<CardStatement> =
(models.CardStatement as Model<CardStatement> | undefined) ??
model<CardStatement>("CardStatement", cardStatementSchema);
+30
View File
@@ -0,0 +1,30 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
/**
* Mutabakat kaydı (Bölüm 6.1): kullanıcı ekstre bakiyesini girdi, uygulama bakiyesiyle
* karşılaştırıldı, fark varsa düzeltme işlemi oluşturuldu ve o tarihe kadarki işlemler işaretlendi.
*/
const reconciliationSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
accountId: { type: Schema.Types.ObjectId, required: true },
/** İstanbul gününün başlangıç anı (UTC); o günün sonuna kadarki işlemler dahil. */
statementDate: { type: Date, required: true },
/** Uygulamadaki işaretle (kredi kartında eksi = borç). */
statementBalanceMinor: { type: Number, required: true },
appBalanceMinor: { type: Number, required: true },
differenceMinor: { type: Number, required: true },
adjustmentTransactionId: { type: Schema.Types.ObjectId, default: null },
markedCount: { type: Number, default: 0 },
createdBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: { createdAt: true, updatedAt: false }, collection: "reconciliations" },
);
reconciliationSchema.index({ householdId: 1, accountId: 1, statementDate: -1, createdAt: -1 });
export type Reconciliation = InferSchemaType<typeof reconciliationSchema>;
export const ReconciliationModel: Model<Reconciliation> =
(models.Reconciliation as Model<Reconciliation> | undefined) ??
model<Reconciliation>("Reconciliation", reconciliationSchema);
+52
View File
@@ -0,0 +1,52 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
import { FREQUENCIES, MONTHLY_MODES } from "@/shared/recurrence";
import { TRANSACTION_TYPES } from "@/shared/transactions";
/**
* Düzenli işlem kuralı (Bölüm 6.7). Her tekrar için `planned` durumunda bir işlem üretilir
* (`transactions.recurringRuleId` + `occurrenceIndex`, benzersiz). Bitiş tarihsiz kurallarda
* ufuk 12 ay; okuma sırasında ilerletilir.
*/
const recurringRuleSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
// İşlem şablonu (bölme yok).
type: { type: String, enum: TRANSACTION_TYPES, required: true },
amountMinor: { type: Number, required: true, min: 1 },
/** Tutar tahmini mi (ör. fatura)? Onaylarken düzeltilir. */
isEstimate: { type: Boolean, default: false },
accountId: { type: Schema.Types.ObjectId, required: true },
toAccountId: { type: Schema.Types.ObjectId, default: null },
categoryId: { type: Schema.Types.ObjectId, default: null },
payeeName: { type: String, default: "", maxlength: 80 },
description: { type: String, default: "", maxlength: 500 },
tags: { type: [String], default: [] },
// Tekrar.
frequency: { type: String, enum: FREQUENCIES, required: true },
interval: { type: Number, default: 1, min: 1, max: 12 },
monthlyMode: { type: String, enum: MONTHLY_MODES, default: "day" },
/** "YYYY-MM-DD" (İstanbul günü). */
startDate: { type: String, required: true },
endDate: { type: String, default: null },
count: { type: Number, default: null, min: 1 },
/** Üretilen son ufuk ("YYYY-MM-DD"); null → hiç üretilmedi. */
generatedUntil: { type: String, default: null },
deletedAt: { type: Date, default: null },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
updatedBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "recurringRules" },
);
recurringRuleSchema.index({ householdId: 1, deletedAt: 1, generatedUntil: 1 });
recurringRuleSchema.index(
{ householdId: 1, seedRef: 1 },
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
);
export type RecurringRule = InferSchemaType<typeof recurringRuleSchema>;
export const RecurringRuleModel: Model<RecurringRule> =
(models.RecurringRule as Model<RecurringRule> | undefined) ??
model<RecurringRule>("RecurringRule", recurringRuleSchema);
+19 -1
View File
@@ -35,9 +35,16 @@ const transactionSchema = new Schema(
/** Arama için normalize metin (açıklama + satıcı + kalem notları). */ /** Arama için normalize metin (açıklama + satıcı + kalem notları). */
searchText: { type: String, default: "" }, searchText: { type: String, default: "" },
tags: { type: [String], default: [] }, tags: { type: [String], default: [] },
/** Bağlı (silinmemiş) ek dosya sayısı; liste ikonu için (asıl kayıt attachments'ta). */
attachmentCount: { type: Number, default: 0 }, attachmentCount: { type: Number, default: 0 },
// İleri fazlar: planlı işlem kuralı (7), taksit (7), kart dönemi (7), cari (8). // İleri fazlar: planlı işlem kuralı (7), taksit (7), kart dönemi (7), cari (8).
recurringRuleId: { type: Schema.Types.ObjectId, default: null }, recurringRuleId: { type: Schema.Types.ObjectId, default: null },
/** Serideki sıra (0'dan); kuralla birlikte benzersiz (tekrar üretimi idempotent). */
occurrenceIndex: { type: Number, default: null },
/** Tek başına düzenlendi/ertelendi: "tümü" güncellemesinde korunur. */
detached: { type: Boolean, default: false },
/** Planlı tutar tahmini (ör. fatura); onaylarken kesinleşir. */
isEstimate: { type: Boolean, default: false },
installment: { installment: {
type: new Schema( type: new Schema(
{ planId: Schema.Types.ObjectId, index: Number, count: Number }, { planId: Schema.Types.ObjectId, index: Number, count: Number },
@@ -47,7 +54,13 @@ const transactionSchema = new Schema(
}, },
statementId: { type: Schema.Types.ObjectId, default: null }, statementId: { type: Schema.Types.ObjectId, default: null },
contactId: { type: Schema.Types.ObjectId, default: null }, contactId: { type: Schema.Types.ObjectId, default: null },
reconciledAt: { type: Date, default: null }, /**
* Mutabakatı yapılmış hesaplar (Bölüm 6.1). Transfer iki hesaba dokunduğu için hesap bazında
* tutulur. Tutar, tarih, tür veya hesap değişirse işaret kalkar.
*/
reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] },
/** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */
isAdjustment: { type: Boolean, default: false },
deletedAt: { type: Date, default: null }, deletedAt: { type: Date, default: null },
/** Seed verisinin idempotent yüklenmesi için sabit anahtar. */ /** Seed verisinin idempotent yüklenmesi için sabit anahtar. */
seedRef: { type: String, default: null }, seedRef: { type: String, default: null },
@@ -63,6 +76,11 @@ transactionSchema.index({ householdId: 1, accountId: 1, date: -1 });
transactionSchema.index({ householdId: 1, toAccountId: 1, date: -1 }); transactionSchema.index({ householdId: 1, toAccountId: 1, date: -1 });
transactionSchema.index({ householdId: 1, effectiveCategoryIds: 1, date: -1 }); transactionSchema.index({ householdId: 1, effectiveCategoryIds: 1, date: -1 });
transactionSchema.index({ householdId: 1, tags: 1 }); transactionSchema.index({ householdId: 1, tags: 1 });
transactionSchema.index({ "installment.planId": 1 }, { sparse: true });
transactionSchema.index(
{ recurringRuleId: 1, occurrenceIndex: 1 },
{ unique: true, partialFilterExpression: { recurringRuleId: { $type: "objectId" } } },
);
transactionSchema.index( transactionSchema.index(
{ householdId: 1, seedRef: 1 }, { householdId: 1, seedRef: 1 },
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } }, { unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
+3 -1
View File
@@ -16,7 +16,9 @@ export function proxy(request: NextRequest) {
export const config = { export const config = {
// Herkese açık yollar hariç: giriş, auth ve health API'leri, statik dosyalar, ikonlar, manifest. // Herkese açık yollar hariç: giriş, auth ve health API'leri, statik dosyalar, ikonlar, manifest.
// /api/files da hariç: route oturumu kendisi kontrol eder (401) ve proxy büyük yükleme
// gövdelerini tamponlamasın (proxyClientMaxBodySize).
matcher: [ matcher: [
"/((?!login|api/auth|api/health|_next/static|_next/image|brand/|icons/|favicon.ico|icon.png|apple-icon.png|manifest.webmanifest|robots.txt).*)", "/((?!login|api/auth|api/health|api/files|_next/static|_next/image|brand/|icons/|favicon.ico|icon.png|apple-icon.png|manifest.webmanifest|robots.txt).*)",
], ],
}; };
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
acceptAttribute,
allowedAttachmentKinds,
detectFileType,
formatFileSize,
kindOf,
} from "./attachments";
const bytes = (...parts: (number[] | string)[]) =>
new Uint8Array(
parts.flatMap((p) => (typeof p === "string" ? [...p].map((c) => c.charCodeAt(0)) : p)),
);
describe("detectFileType", () => {
it("imzalardan türü bulur", () => {
expect(detectFileType(bytes([0xff, 0xd8, 0xff, 0xe0]))).toBe("jpeg");
expect(detectFileType(bytes([0x89], "PNG", [0x0d, 0x0a, 0x1a, 0x0a]))).toBe("png");
expect(detectFileType(bytes("RIFF", [0, 0, 0, 0], "WEBPVP8 "))).toBe("webp");
expect(detectFileType(bytes("%PDF-1.7"))).toBe("pdf");
});
it("HEIC: ana veya uyumlu markadan", () => {
expect(detectFileType(bytes([0, 0, 0, 24], "ftypheic", [0, 0, 0, 0], "mif1heic"))).toBe("heic");
expect(detectFileType(bytes([0, 0, 0, 28], "ftypmif1", [0, 0, 0, 0], "mif1heichevc"))).toBe(
"heic",
);
// MP4 videosu HEIC değildir.
expect(detectFileType(bytes([0, 0, 0, 24], "ftypisom", [0, 0, 2, 0], "isomiso2"))).toBeNull();
});
it("uzantı/metin/kısa içerik reddedilir", () => {
expect(detectFileType(bytes("<svg xmlns="))).toBeNull();
expect(detectFileType(bytes("GIF89a"))).toBeNull();
expect(detectFileType(bytes([0xff, 0xd8]))).toBeNull();
expect(detectFileType(new Uint8Array())).toBeNull();
});
});
describe("tür kuralları", () => {
it("gider yalnızca görsel; gelir ve transfer görsel + PDF", () => {
expect(allowedAttachmentKinds("expense")).toEqual(["image"]);
expect(allowedAttachmentKinds("income")).toEqual(["image", "pdf"]);
expect(allowedAttachmentKinds("transfer")).toEqual(["image", "pdf"]);
expect(acceptAttribute(["image"])).not.toContain("pdf");
expect(acceptAttribute(["image", "pdf"])).toContain("application/pdf");
expect(kindOf("heic")).toBe("image");
expect(kindOf("pdf")).toBe("pdf");
});
it("boyut biçimi", () => {
expect(formatFileSize(900)).toBe("900 B");
expect(formatFileSize(2048)).toBe("2 KB");
expect(formatFileSize(1.5 * 1024 * 1024)).toBe("1,5 MB");
});
});
+78
View File
@@ -0,0 +1,78 @@
import type { TransactionType } from "./transactions";
/** Ek dosya kuralları (Bölüm 6.3, 7.4). Sunucu ve istemci aynı sabitleri kullanır. */
export const MAX_ATTACHMENTS_PER_RECORD = 10;
export const ATTACHMENT_KINDS = ["image", "pdf"] as const;
export type AttachmentKind = (typeof ATTACHMENT_KINDS)[number];
/** Dosya içeriğinden tespit edilen kaynak türler (uzantıya güvenilmez). */
export type DetectedType = "jpeg" | "png" | "webp" | "heic" | "pdf";
export function kindOf(type: DetectedType): AttachmentKind {
return type === "pdf" ? "pdf" : "image";
}
/**
* İşlem türüne göre izinli ek türleri: gider yalnızca görsel (fiş/fatura fotoğrafı);
* gelir görsel veya PDF (ör. bordro); transfer görsel veya PDF (dekont).
*/
export function allowedAttachmentKinds(type: TransactionType): readonly AttachmentKind[] {
return type === "expense" ? ["image"] : ["image", "pdf"];
}
/** `<input accept>` değeri (yalnızca seçici filtresi; asıl doğrulama sunucuda). */
export function acceptAttribute(kinds: readonly AttachmentKind[]): string {
const image = "image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif";
return kinds.includes("pdf") ? `${image},application/pdf,.pdf` : image;
}
/** Dosya içeriğinin ilk baytlarından türü bulur; desteklenmeyen içerikte null. */
export function detectFileType(bytes: Uint8Array): DetectedType | null {
const at = (offset: number, sig: number[]) =>
bytes.length >= offset + sig.length && sig.every((b, i) => bytes[offset + i] === b);
const ascii = (offset: number, text: string) =>
at(
offset,
[...text].map((c) => c.charCodeAt(0)),
);
if (at(0, [0xff, 0xd8, 0xff])) return "jpeg";
if (at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "webp";
if (ascii(0, "%PDF-")) return "pdf";
// ISO BMFF: [boyut][ftyp][ana marka][sürüm][uyumlu markalar…]
if (ascii(4, "ftyp")) {
const boxSize = Math.min(
bytes.length,
((bytes[0]! << 24) | (bytes[1]! << 16) | (bytes[2]! << 8) | bytes[3]!) >>> 0,
);
const brands: string[] = [];
const brandAt = (o: number) => String.fromCharCode(...bytes.subarray(o, o + 4));
brands.push(brandAt(8));
for (let o = 16; o + 4 <= boxSize && o < 64; o += 4) brands.push(brandAt(o));
if (brands.some((b) => HEIF_BRANDS.has(b))) return "heic";
}
return null;
}
const HEIF_BRANDS = new Set([
"heic",
"heix",
"heim",
"heis",
"hevc",
"hevx",
"hevm",
"hevs",
"mif1",
"msf1",
]);
/** "1,2 MB" gibi okunur boyut. */
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${Math.round(kb)} KB`;
return `${(kb / 1024).toLocaleString("tr-TR", { maximumFractionDigits: 1 })} MB`;
}
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
cardPeriods,
closingOnOrAfter,
dueDate,
estimateOverdraftInterest,
installmentSchedule,
minimumPayment,
minimumRateBp,
nextPeriod,
periodContaining,
} from "./card-periods";
describe("dönem hesabı", () => {
it("kesim günü dahil hesap kesim dönemine girer; ertesi gün yeni dönem", () => {
expect(periodContaining("2026-10-29", 29)).toEqual({ start: "2026-09-30", end: "2026-10-29" });
expect(periodContaining("2026-10-30", 29)).toEqual({ start: "2026-10-30", end: "2026-11-29" });
});
it("kesim günü ayda yoksa ayın son günü (31 → Şubat 28/29)", () => {
expect(closingOnOrAfter("2026-02-10", 31)).toBe("2026-02-28");
expect(periodContaining("2028-03-05", 31)).toEqual({ start: "2028-03-01", end: "2028-03-31" });
expect(periodContaining("2026-03-01", 30)).toEqual({ start: "2026-03-01", end: "2026-03-30" });
expect(periodContaining("2026-02-28", 30)).toEqual({ start: "2026-01-31", end: "2026-02-28" });
});
it("yıl dönümü ve sonraki dönem", () => {
const p = periodContaining("2026-12-31", 15);
expect(p).toEqual({ start: "2026-12-16", end: "2027-01-15" });
expect(nextPeriod(p, 15)).toEqual({ start: "2027-01-16", end: "2027-02-15" });
});
it("güncel dönem ve son ekstre; kesim günü bugün iken dönem henüz kapanmamış", () => {
expect(cardPeriods("2026-11-05", 29)).toEqual({
current: { start: "2026-10-30", end: "2026-11-29" },
statement: { start: "2026-09-30", end: "2026-10-29" },
});
expect(cardPeriods("2026-11-29", 29).current.end).toBe("2026-11-29");
});
it("son ödeme tarihi", () => {
expect(dueDate("2026-10-29", { kind: "daysAfter", value: 10 })).toBe("2026-11-08");
expect(dueDate("2026-10-29", { kind: "fixedDay", value: 9 })).toBe("2026-11-09");
expect(dueDate("2026-10-05", { kind: "fixedDay", value: 20 })).toBe("2026-10-20");
expect(dueDate("2026-01-29", { kind: "fixedDay", value: 31 })).toBe("2026-01-31");
});
});
describe("asgari ödeme", () => {
it("limite göre %20 / %40; kart bazında oran öncelikli; yukarı yuvarlama", () => {
expect(minimumRateBp(49_999_99, null)).toBe(2000);
expect(minimumRateBp(50_000_00, null)).toBe(4000);
expect(minimumRateBp(60_000_00, 2500)).toBe(2500);
expect(minimumPayment(10_000_01, 2000)).toBe(2_000_01);
expect(minimumPayment(-5, 2000)).toBe(0);
});
});
describe("taksit planı", () => {
it("eşit bölme (kuruş farkı ilk taksitlerde), aylık tarihler, ay sonu", () => {
const s = installmentSchedule("2026-01-31", 10_000_00, 3);
expect(s.map((x) => x.amountMinor)).toEqual([3_333_34, 3_333_33, 3_333_33]);
expect(s.reduce((a, x) => a + x.amountMinor, 0)).toBe(10_000_00);
expect(s.map((x) => x.date)).toEqual(["2026-01-31", "2026-02-28", "2026-03-31"]);
});
});
describe("KMH faiz tahmini", () => {
it("gün sonu eksi bakiyeler üzerinden", () => {
const period = { start: "2026-11-01", end: "2026-11-30" };
// 10 gün boyunca -3.000 TL; sonra sıfır.
const deltas = new Map([
["2026-11-01", -3_000_00],
["2026-11-11", 3_000_00],
]);
const r = estimateOverdraftInterest(period, 0, deltas, 425);
// 10 gün × 3.000 TL × %4,25 / 30 = 42,50 TL
expect(r.interestMinor).toBe(42_50);
expect(r.days).toBe(30);
expect(estimateOverdraftInterest(period, 5_000_00, deltas, 425).interestMinor).toBe(0);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { splitEvenly } from "./money";
import { addDays, addMonths, daysInMonth } from "./recurrence";
/**
* Kredi kartı ve KMH dönemleri (Bölüm 6.8). Saf fonksiyonlar; tarihler "YYYY-MM-DD" (İstanbul).
*
* - Kesim günü ayda yoksa (ör. 31) ayın son günü kullanılır.
* - Kesim günü DAHİL o gün ve öncesi "Hesap Kesim Dönemi"ne girer (Bölüm 10 varsayılanı).
* - Kesim günü bitmeden dönem kapanmış sayılmaz: bugün kesim günüyse işlemler hâlâ "Güncel Dönem"de.
*/
export type DueRule = { kind: "daysAfter" | "fixedDay"; value: number };
export type Period = { start: string; end: string };
/** Asgari ödeme varsayılanı (Faz 7 kararı): limit < 50.000 TL → %20, üstü → %40. */
export const MINIMUM_RATE_THRESHOLD_MINOR = 50_000_00;
export const MAX_INSTALLMENTS = 36;
/** O ayın kesim tarihi. */
export function closingDateIn(ym: string, statementDay: number): string {
const [y, m] = ym.split("-").map(Number) as [number, number];
const day = Math.min(statementDay, daysInMonth(y, m));
return `${ym}-${String(day).padStart(2, "0")}`;
}
/** Tarihi içeren dönemin kesim tarihi (tarih kesim gününe eşitse o gün). */
export function closingOnOrAfter(ymd: string, statementDay: number): string {
const c = closingDateIn(ymd.slice(0, 7), statementDay);
return ymd <= c
? c
: closingDateIn(addMonths(`${ymd.slice(0, 7)}-01`, 1).slice(0, 7), statementDay);
}
/** Tarihi içeren dönem: önceki kesimin ertesi günü → bu kesim (dahil). */
export function periodContaining(ymd: string, statementDay: number): Period {
const end = closingOnOrAfter(ymd, statementDay);
const prevMonth = addMonths(`${end.slice(0, 7)}-01`, -1).slice(0, 7);
return { start: addDays(closingDateIn(prevMonth, statementDay), 1), end };
}
export function previousPeriod(p: Period, statementDay: number): Period {
return periodContaining(addDays(p.start, -1), statementDay);
}
export function nextPeriod(p: Period, statementDay: number): Period {
return periodContaining(addDays(p.end, 1), statementDay);
}
/** Son ödeme tarihi: kesimden N gün sonra veya kesimden sonraki ilk "ayın N. günü". */
export function dueDate(closing: string, rule: DueRule): string {
if (rule.kind === "daysAfter") return addDays(closing, rule.value);
const same = addMonths(closing, 0, rule.value);
return same > closing ? same : addMonths(closing, 1, rule.value);
}
/** Bugüne göre güncel dönem (bugünü içerir) ve son kesilmiş dönem (ekstre). */
export function cardPeriods(today: string, statementDay: number) {
const current = periodContaining(today, statementDay);
return { current, statement: previousPeriod(current, statementDay) };
}
export function minimumRateBp(limitMinor: number, overrideBp: number | null | undefined): number {
if (overrideBp !== null && overrideBp !== undefined) return overrideBp;
return limitMinor < MINIMUM_RATE_THRESHOLD_MINOR ? 2000 : 4000;
}
/** Asgari ödeme (kuruş, yukarı yuvarlanır). */
export function minimumPayment(debtMinor: number, rateBp: number): number {
if (debtMinor <= 0) return 0;
return Math.ceil((debtMinor * rateBp) / 10_000);
}
/**
* Taksit planı: toplam eşit bölünür (kuruş farkı ilk taksitlere), her taksit alışveriş gününden
* itibaren aylık (ayda o gün yoksa son gün) — böylece her taksit ardışık bir döneme düşer.
*/
export function installmentSchedule(
purchaseYmd: string,
totalMinor: number,
count: number,
): { index: number; date: string; amountMinor: number }[] {
const day = Number(purchaseYmd.slice(8, 10));
return splitEvenly(totalMinor, count).map((amountMinor, index) => ({
index,
date: addMonths(purchaseYmd, index, day),
amountMinor,
}));
}
/**
* KMH faizi tahmini: dönemdeki her günün gün sonu eksi bakiyesi × (aylık oran / 30).
* `startBalance`: dönem başından önceki bakiye; `deltas`: gün → o günün net hareketi.
* Vergiler (KKDF/BSMV) dahil değildir; kullanıcı kesim gününde kesinleştirir.
*/
export function estimateOverdraftInterest(
period: Period,
startBalanceMinor: number,
deltas: ReadonlyMap<string, number>,
monthlyRateBp: number,
until?: string,
): { interestMinor: number; averageUsedMinor: number; days: number } {
let balance = startBalanceMinor;
let used = 0;
let days = 0;
const last = until && until < period.end ? until : period.end;
for (let d = period.start; d <= last; d = addDays(d, 1)) {
balance += deltas.get(d) ?? 0;
used += Math.max(0, -balance);
days++;
}
const interestMinor = Math.round((used * monthlyRateBp) / 10_000 / 30);
return { interestMinor, averageUsedMinor: days ? Math.round(used / days) : 0, days };
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import {
addDays,
addMonths,
describeRecurrence,
isFinished,
listOccurrences,
occurrenceDate,
type Recurrence,
} from "./recurrence";
const rule = (over: Partial<Recurrence>): Recurrence => ({
frequency: "monthly",
interval: 1,
monthlyMode: "day",
startDate: "2026-01-31",
endDate: null,
count: null,
...over,
});
describe("tarih aritmetiği", () => {
it("gün ve ay ekleme, ay sonu taşması", () => {
expect(addDays("2026-12-30", 3)).toBe("2027-01-02");
expect(addDays("2028-03-01", -1)).toBe("2028-02-29");
expect(addMonths("2026-01-31", 1)).toBe("2026-02-28");
expect(addMonths("2026-01-31", 1, 31)).toBe("2026-02-28");
expect(addMonths("2026-11-15", 3)).toBe("2027-02-15");
expect(addMonths("2026-04-10", 0, "last")).toBe("2026-04-30");
});
});
describe("occurrenceDate", () => {
it("aylık: başlangıç günü korunur, ayda yoksa son gün", () => {
const r = rule({});
expect([0, 1, 2, 3].map((i) => occurrenceDate(r, i))).toEqual([
"2026-01-31",
"2026-02-28",
"2026-03-31",
"2026-04-30",
]);
});
it("aylık son gün modu (artık yıl dahil)", () => {
const r = rule({ monthlyMode: "last", startDate: "2028-01-10" });
expect([0, 1, 2].map((i) => occurrenceDate(r, i))).toEqual([
"2028-01-31",
"2028-02-29",
"2028-03-31",
]);
});
it("günlük, haftalık (aralıkla), yıllık (29 Şubat)", () => {
expect(
occurrenceDate(rule({ frequency: "daily", interval: 3, startDate: "2026-11-30" }), 1),
).toBe("2026-12-03");
expect(
occurrenceDate(rule({ frequency: "weekly", interval: 2, startDate: "2026-11-02" }), 2),
).toBe("2026-11-30");
const y = rule({ frequency: "yearly", startDate: "2028-02-29" });
expect([1, 4].map((i) => occurrenceDate(y, i))).toEqual(["2029-02-28", "2032-02-29"]);
});
});
describe("listOccurrences", () => {
it("tekrar sayısı, bitiş tarihi ve ufuk sınırları", () => {
const r = rule({ startDate: "2026-01-05", count: 3 });
expect(listOccurrences(r, "2030-01-01").map((o) => o.date)).toEqual([
"2026-01-05",
"2026-02-05",
"2026-03-05",
]);
expect(isFinished(r, 3)).toBe(true);
const e = rule({ startDate: "2026-01-05", endDate: "2026-02-10" });
expect(listOccurrences(e, "2030-01-01")).toHaveLength(2);
const h = rule({ startDate: "2026-01-05" });
expect(listOccurrences(h, "2026-04-05").map((o) => o.index)).toEqual([0, 1, 2, 3]);
expect(listOccurrences(h, "2026-04-05", 2).map((o) => o.index)).toEqual([2, 3]);
expect(isFinished(h, 100)).toBe(false);
});
});
describe("describeRecurrence", () => {
it("Türkçe açıklama", () => {
expect(describeRecurrence(rule({ startDate: "2026-11-03" }))).toBe("Her ay 3. gün");
expect(describeRecurrence(rule({ monthlyMode: "last" }))).toBe("Her ayın son günü");
expect(
describeRecurrence(rule({ frequency: "weekly", interval: 2, startDate: "2026-11-02" })),
).toBe("2 haftada bir Pazartesi");
expect(describeRecurrence(rule({ frequency: "yearly", startDate: "2026-03-15" }))).toBe(
"Her yıl 15 Mart",
);
expect(describeRecurrence(rule({ frequency: "daily" }))).toBe("Her gün");
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* Düzenli işlem tekrarı (Bölüm 6.7). Saf fonksiyonlar: tarihler "YYYY-MM-DD" (İstanbul günü).
* Aylık/yıllık tekrarda gün, başlangıç tarihinin gününden alınır ve ayda yoksa ayın son günü
* kullanılır (31 Ocak → 28/29 Şubat → 31 Mart). "Ayın son günü" modu her zaman son günü verir.
*/
export const FREQUENCIES = ["daily", "weekly", "monthly", "yearly"] as const;
export type Frequency = (typeof FREQUENCIES)[number];
export const MONTHLY_MODES = ["day", "last"] as const;
export type MonthlyMode = (typeof MONTHLY_MODES)[number];
/** Planlı kayıtların üretileceği ufuk (ay). */
export const HORIZON_MONTHS = 12;
export const MAX_OCCURRENCES = 600;
export type Recurrence = {
frequency: Frequency;
/** Her N gün/hafta/ay/yılda bir (1–12). */
interval: number;
monthlyMode: MonthlyMode;
startDate: string;
endDate: string | null;
/** Toplam tekrar sayısı (ör. 12) — endDate ile birlikte verilirse hangisi önce gelirse. */
count: number | null;
};
const parse = (ymd: string) => {
const [y, m, d] = ymd.split("-").map(Number) as [number, number, number];
return { y, m, d };
};
const fmt = (y: number, m: number, d: number) =>
`${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
export function daysInMonth(y: number, m: number): number {
return new Date(Date.UTC(y, m, 0)).getUTCDate();
}
export function addDays(ymd: string, days: number): string {
const { y, m, d } = parse(ymd);
const t = new Date(Date.UTC(y, m - 1, d + days));
return fmt(t.getUTCFullYear(), t.getUTCMonth() + 1, t.getUTCDate());
}
/** Ay ekler; gün ayda yoksa ayın son günü. `day` verilirse o gün hedeflenir. */
export function addMonths(ymd: string, months: number, day?: number | "last"): string {
const { y, m, d } = parse(ymd);
const index = y * 12 + (m - 1) + months;
const ny = Math.floor(index / 12);
const nm = (index % 12) + 1;
const last = daysInMonth(ny, nm);
const target = day === "last" ? last : Math.min(day ?? d, last);
return fmt(ny, nm, target);
}
/** index. tekrarın tarihi (0 = başlangıç; "son gün" modunda başlangıç ayının son günü). */
export function occurrenceDate(r: Recurrence, index: number): string {
const n = index * r.interval;
switch (r.frequency) {
case "daily":
return addDays(r.startDate, n);
case "weekly":
return addDays(r.startDate, n * 7);
case "monthly":
return addMonths(r.startDate, n, r.monthlyMode === "last" ? "last" : parse(r.startDate).d);
case "yearly":
return addMonths(r.startDate, n * 12, parse(r.startDate).d);
}
}
/** Ufuk tarihine (dahil) kadar, `fromIndex`ten başlayarak tekrarlar. */
export function listOccurrences(
r: Recurrence,
until: string,
fromIndex = 0,
): { index: number; date: string }[] {
const out: { index: number; date: string }[] = [];
for (let i = fromIndex; i < MAX_OCCURRENCES; i++) {
if (r.count !== null && i >= r.count) break;
const date = occurrenceDate(r, i);
if (r.endDate && date > r.endDate) break;
if (date > until) break;
out.push({ index: i, date });
}
return out;
}
/** Seri bitti mi (bir sonraki tekrar yok)? */
export function isFinished(r: Recurrence, nextIndex: number): boolean {
if (r.count !== null && nextIndex >= r.count) return true;
return Boolean(r.endDate && occurrenceDate(r, nextIndex) > r.endDate);
}
export function horizonDate(today: string): string {
return addMonths(today, HORIZON_MONTHS);
}
const MONTHS = [
"Ocak",
"Şubat",
"Mart",
"Nisan",
"Mayıs",
"Haziran",
"Temmuz",
"Ağustos",
"Eylül",
"Ekim",
"Kasım",
"Aralık",
];
const WEEKDAYS = ["Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"];
/** "Her ay 3. gün", "2 haftada bir Pazartesi", "Her ayın son günü", "Her yıl 15 Mart". */
export function describeRecurrence(
r: Pick<Recurrence, "frequency" | "interval" | "monthlyMode" | "startDate">,
): string {
const { y, m, d } = parse(r.startDate);
const every = (unit: string) =>
r.interval === 1 ? `Her ${unit}` : `${r.interval} ${unit}da bir`;
switch (r.frequency) {
case "daily":
return r.interval === 1 ? "Her gün" : `${r.interval} günde bir`;
case "weekly": {
const wd = WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]!;
return r.interval === 1 ? `Her hafta ${wd}` : `${r.interval} haftada bir ${wd}`;
}
case "monthly":
if (r.monthlyMode === "last")
return r.interval === 1 ? "Her ayın son günü" : `${r.interval} ayda bir, ayın son günü`;
return r.interval === 1 ? `Her ay ${d}. gün` : `${every("ay")}, ${d}. gün`;
case "yearly":
return r.interval === 1
? `Her yıl ${d} ${MONTHS[m - 1]}`
: `${r.interval} yılda bir, ${d} ${MONTHS[m - 1]}`;
}
}
+2
View File
@@ -68,6 +68,8 @@ export const accountFormSchema = z
cardDueKind: z.enum(DUE_RULE_KINDS), cardDueKind: z.enum(DUE_RULE_KINDS),
cardDueValue: day(31), cardDueValue: day(31),
cardPaymentAccountId: objectIdOrEmpty, cardPaymentAccountId: objectIdOrEmpty,
/** Boş → varsayılan kural (limite göre %20 / %40). */
cardMinimumRate: z.string().default("").pipe(percentToBp),
}) })
.superRefine((a, ctx) => { .superRefine((a, ctx) => {
if (a.type === "meal_card" && !a.mealCardProvider) { if (a.type === "meal_card" && !a.mealCardProvider) {
+29
View File
@@ -0,0 +1,29 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { parseMoney } from "../money";
const v = tr.validation;
/**
* Mutabakat formu (web + mobil). `statementBalance` ekstredeki bakiye; kredi kartında
* ekstredeki BORÇ tutarı pozitif girilir (sunucu uygulamadaki eksi bakiyeye çevirir).
*/
export const reconcileSchema = z.object({
statementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date),
statementBalance: z
.string()
.trim()
.transform((s, ctx) => {
const minor = parseMoney(s);
if (minor === null) {
ctx.addIssue({ code: "custom", message: v.money });
return z.NEVER;
}
return minor;
}),
/** Fark varsa düzeltme işlemi oluşturulmasını onaylar. */
acceptAdjustment: z.boolean().default(false),
});
export type ReconcileInput = z.input<typeof reconcileSchema>;
export type ReconcileForm = z.output<typeof reconcileSchema>;
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { recurringRuleFormSchema } from "./recurring";
/** Formun gönderdiği biçim: tüm alanlar string, seçilmeyenler boş. */
const formValues = {
type: "expense",
amount: "1.250,00",
isEstimate: false,
accountId: "a".repeat(24),
toAccountId: "",
categoryId: "b".repeat(24),
payee: "",
description: "",
tags: "",
startDate: "2026-11-05",
frequency: "monthly",
interval: "1",
monthlyMode: "day",
endKind: "never",
count: "",
endDate: "",
};
describe("recurringRuleFormSchema", () => {
it("boş isteğe bağlı alanlarla geçerli; bitiş türüne göre count/endDate ayıklanır", () => {
const x = recurringRuleFormSchema.parse(formValues);
expect(x).toMatchObject({ amount: 125000, interval: 1, count: null, endDate: null });
const c = recurringRuleFormSchema.parse({ ...formValues, endKind: "count", count: "12" });
expect(c.count).toBe(12);
const d = recurringRuleFormSchema.parse({
...formValues,
endKind: "date",
endDate: "2027-01-01",
count: "5",
});
expect(d).toMatchObject({ endDate: "2027-01-01", count: null });
});
it("seçilen bitiş türünün değeri zorunlu", () => {
expect(recurringRuleFormSchema.safeParse({ ...formValues, endKind: "count" }).success).toBe(
false,
);
expect(recurringRuleFormSchema.safeParse({ ...formValues, endKind: "date" }).success).toBe(
false,
);
expect(recurringRuleFormSchema.safeParse({ ...formValues, interval: "13" }).success).toBe(
false,
);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { parseMoney } from "../money";
import { FREQUENCIES, MONTHLY_MODES } from "../recurrence";
import { normalizeTags, TRANSACTION_TYPES } from "../transactions";
const v = tr.validation;
const t = tr.transactions.validation;
const r = tr.recurring.validation;
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
const optionalId = z
.string()
.regex(/^([a-f0-9]{24})?$/i)
.nullable()
.optional()
.transform((s) => (s ? s : null));
const positiveMoney = z
.string()
.trim()
.transform((s, ctx) => {
const minor = parseMoney(s);
if (minor === null || minor <= 0) {
ctx.addIssue({ code: "custom", message: minor === null ? v.money : t.amountPositive });
return z.NEVER;
}
return minor;
});
/** Formdan gelen boş alanlar (""), seçilmemiş isteğe bağlı değer sayılır. */
const emptyToNull = (v: unknown) => (v === "" || v === undefined ? null : v);
export const END_KINDS = ["never", "count", "date"] as const;
/** Düzenli işlem kuralı formu (web + mobil). Bölme yok; onaylarken tutar düzeltilebilir. */
export const recurringRuleFormSchema = z
.object({
type: z.enum(TRANSACTION_TYPES),
amount: positiveMoney,
isEstimate: z.boolean().default(false),
accountId: z.string().regex(/^[a-f0-9]{24}$/i, t.selectAccount),
toAccountId: optionalId,
categoryId: optionalId,
payee: z.string().trim().max(80).default(""),
description: z.string().trim().max(500).default(""),
tags: z
.union([z.string(), z.array(z.string())])
.default("")
.transform((x) => normalizeTags(x)),
startDate: ymd,
frequency: z.enum(FREQUENCIES),
interval: z.coerce.number().int().min(1, r.interval).max(12, r.interval).default(1),
monthlyMode: z.enum(MONTHLY_MODES).default("day"),
endKind: z.enum(END_KINDS).default("never"),
count: z.preprocess(
emptyToNull,
z.coerce.number().int().min(1, r.count).max(600, r.count).nullable().optional(),
),
endDate: z.preprocess(emptyToNull, ymd.nullable().optional()),
})
.superRefine((x, ctx) => {
if (x.type === "transfer") {
if (!x.toAccountId)
ctx.addIssue({ code: "custom", path: ["toAccountId"], message: t.selectToAccount });
else if (x.toAccountId === x.accountId)
ctx.addIssue({ code: "custom", path: ["toAccountId"], message: t.sameAccount });
} else if (!x.categoryId) {
ctx.addIssue({ code: "custom", path: ["categoryId"], message: t.selectCategory });
}
if (x.endKind === "count" && !x.count)
ctx.addIssue({ code: "custom", path: ["count"], message: r.count });
if (x.endKind === "date") {
if (!x.endDate) ctx.addIssue({ code: "custom", path: ["endDate"], message: v.date });
else if (x.endDate < x.startDate)
ctx.addIssue({ code: "custom", path: ["endDate"], message: r.endBeforeStart });
}
})
.transform((x) => ({
...x,
count: x.endKind === "count" ? (x.count ?? null) : null,
endDate: x.endKind === "date" ? (x.endDate ?? null) : null,
}));
export type RecurringRuleFormInput = z.input<typeof recurringRuleFormSchema>;
export type RecurringRuleForm = z.output<typeof recurringRuleFormSchema>;
/** Planlı kaydı onaylama: tutar ve tarih düzeltilebilir. */
export const confirmOccurrenceSchema = z.object({
amount: positiveMoney,
date: ymd,
});
export type ConfirmOccurrenceForm = z.output<typeof confirmOccurrenceSchema>;
export const SERIES_SCOPES = ["one", "following", "all"] as const;
export type SeriesScope = (typeof SERIES_SCOPES)[number];
+18
View File
@@ -1,6 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { tr } from "../../i18n/tr"; import { tr } from "../../i18n/tr";
import { parseMoney } from "../money"; import { parseMoney } from "../money";
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
import { MAX_INSTALLMENTS } from "../card-periods";
import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions"; import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions";
const v = tr.validation; const v = tr.validation;
@@ -52,6 +54,19 @@ export const transactionFormSchema = z
.union([z.string(), z.array(z.string())]) .union([z.string(), z.array(z.string())])
.default("") .default("")
.transform((x) => normalizeTags(x)), .transform((x) => normalizeTags(x)),
/**
* Taksit sayısı (yalnızca kredi kartı gideri, oluştururken). 1 = peşin. Her taksit ayrı işlem
* olur; vadesi gelmeyenler planlıdır ve kendi döneminde otomatik gerçekleşir.
*/
installments: z.preprocess(
(v) => (v === "" || v === null || v === undefined ? 1 : v),
z.coerce.number().int().min(1).max(MAX_INSTALLMENTS, t.installmentsMax),
),
/** Bağlı ek dosyalar (sıralı). Yeni ekler önce /api/files ile yüklenir. */
attachmentIds: z
.array(z.string().regex(/^[a-f0-9]{24}$/i))
.max(MAX_ATTACHMENTS_PER_RECORD, tr.attachments.errors.tooMany(MAX_ATTACHMENTS_PER_RECORD))
.default([]),
}) })
.superRefine((x, ctx) => { .superRefine((x, ctx) => {
if (x.type === "transfer") { if (x.type === "transfer") {
@@ -61,6 +76,9 @@ export const transactionFormSchema = z
ctx.addIssue({ code: "custom", path: ["toAccountId"], message: t.sameAccount }); ctx.addIssue({ code: "custom", path: ["toAccountId"], message: t.sameAccount });
return; return;
} }
if (x.installments > 1 && (x.type !== "expense" || x.splits.length > 0)) {
ctx.addIssue({ code: "custom", path: ["installments"], message: t.installmentsExpenseOnly });
}
if (x.splits.length === 1) { if (x.splits.length === 1) {
ctx.addIssue({ code: "custom", path: ["splits"], message: t.splitMin }); ctx.addIssue({ code: "custom", path: ["splits"], message: t.splitMin });
} else if (x.splits.length >= 2) { } else if (x.splits.length >= 2) {
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
accountEffect,
monthRange,
MONTH_PATTERN,
shiftMonth,
withRunningBalance,
} from "./statement";
describe("ay yardımcıları", () => {
it("monthRange ve shiftMonth yıl sınırını aşar", () => {
expect(monthRange("2026-12")).toEqual({ start: "2026-12-01", end: "2027-01-01" });
expect(shiftMonth("2026-01", -1)).toBe("2025-12");
expect(shiftMonth("2026-11", 14)).toBe("2028-01");
expect(MONTH_PATTERN.test("2026-13")).toBe(false);
expect(MONTH_PATTERN.test("2026-09")).toBe(true);
});
});
describe("accountEffect", () => {
const a = "A";
it("gelir +, gider −, transfer kaynak − / hedef +, ilgisiz 0", () => {
expect(accountEffect({ type: "income", amountMinor: 500, accountId: a }, a)).toBe(500);
expect(accountEffect({ type: "expense", amountMinor: 500, accountId: a }, a)).toBe(-500);
const tr = { type: "transfer" as const, amountMinor: 300, accountId: a, toAccountId: "B" };
expect(accountEffect(tr, a)).toBe(-300);
expect(accountEffect(tr, "B")).toBe(300);
expect(accountEffect(tr, "C")).toBe(0);
expect(accountEffect({ type: "expense", amountMinor: 1, accountId: "B" }, a)).toBe(0);
});
});
describe("withRunningBalance", () => {
it("açılıştan başlayarak biriktirir", () => {
expect(
withRunningBalance(1000, [{ deltaMinor: -200 }, { deltaMinor: 50 }]).map(
(r) => r.balanceMinor,
),
).toEqual([800, 850]);
expect(withRunningBalance(5, [])).toEqual([]);
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { TransactionType } from "./transactions";
/** Hesap ekstresi yardımcıları (Bölüm 6.1). Saf fonksiyonlar; sunucu ve testler kullanır. */
export const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/;
/** "2026-11" → { start: "2026-11-01", end: "2026-12-01" } (end hariç). */
export function monthRange(ym: string): { start: string; end: string } {
return { start: `${ym}-01`, end: `${shiftMonth(ym, 1)}-01` };
}
/** "2026-01", -1 → "2025-12". */
export function shiftMonth(ym: string, delta: number): string {
const [y, m] = ym.split("-").map(Number) as [number, number];
const index = y * 12 + (m - 1) + delta;
return `${Math.floor(index / 12)}-${String((index % 12) + 1).padStart(2, "0")}`;
}
/** Bir işlemin belirli bir hesaba etkisi (kuruş): gelir +, gider −, transfer kaynak − / hedef +. */
export function accountEffect(
tx: {
type: TransactionType;
amountMinor: number;
accountId: string;
toAccountId?: string | null;
},
accountId: string,
): number {
if (tx.type === "transfer") {
if (tx.accountId === accountId) return -tx.amountMinor;
if (tx.toAccountId === accountId) return tx.amountMinor;
return 0;
}
if (tx.accountId !== accountId) return 0;
return tx.type === "income" ? tx.amountMinor : -tx.amountMinor;
}
/** Kronolojik sıralı etkilere yürüyen bakiye ekler. */
export function withRunningBalance<T extends { deltaMinor: number }>(
openingMinor: number,
rows: readonly T[],
): (T & { balanceMinor: number })[] {
let balance = openingMinor;
return rows.map((r) => {
balance += r.deltaMinor;
return { ...r, balanceMinor: balance };
});
}
+5
View File
@@ -0,0 +1,5 @@
declare module "heic-decode" {
type Decoded = { width: number; height: number; data: Uint8ClampedArray };
function decode(input: { buffer: Uint8Array | ArrayBuffer }): Promise<Decoded>;
export default decode;
}
+4
View File
@@ -1,3 +1,5 @@
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { loadEnv } from "vite"; import { loadEnv } from "vite";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
@@ -35,6 +37,8 @@ export default defineConfig({
SEED_ADMIN_EMAIL: "admin@test.local", SEED_ADMIN_EMAIL: "admin@test.local",
SEED_ADMIN_PASSWORD: "Test-Admin-12345", SEED_ADMIN_PASSWORD: "Test-Admin-12345",
SEED_USER_PASSWORD: "Test-User-12345", SEED_USER_PASSWORD: "Test-User-12345",
// Ek dosyalar geçici dizine (her çalıştırmada test dosyası temizler).
UPLOAD_DIR: path.join(tmpdir(), "butcem-test-uploads"),
}, },
}, },
}); });