Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 660c6d5f2d | |||
| c53fe80241 | |||
| b022c1c3b0 | |||
| 9a60085d14 | |||
| 20e5a90df4 | |||
| b8306b3b5d | |||
| a818c110cf | |||
| 890ba9f521 | |||
| baec70d324 |
+37
-1
@@ -6,6 +6,38 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
|||||||
|
|
||||||
## [Yayınlanmamış]
|
## [Yayınlanmamış]
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-11-06
|
||||||
|
|
||||||
|
Faz 9: birikim ve varlık takibi, fiyat tablosu (elle), kâr/zarar, net varlık (9a ve 9b alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 9b: dönemsel rapor ve net varlık
|
||||||
|
|
||||||
|
- Dönemsel rapor (`/birikim` toplam, `/birikim/:id` varlık bazında): 3 ay / 6 ay / 12 ay / tümü (`?donem=`); dönem başı değer, yatırılan TL, dönem sonu değer, satış geliri, gelen/çıkan hediye ve dönem kâr/zararı (TL ve %, dönemde bağlanan sermayeye oranla). Formül sayfada yazılıdır.
|
||||||
|
- Aylık değer grafiği (shadcn charts / Recharts): güncel değer (düz çizgi) ve maliyet (kesikli, basamaklı), lejant, ipucu ve tablo görünümü. Fiyatı olmayan varlık toplam rapora katılmaz ve belirtilir.
|
||||||
|
- Net varlık (`/birikim` üstünde): hesap bakiyeleri + birikim (alış fiyatı) + cari alacaklar + aynî alacaklar (alış) − kart/KMH borçları − cari borçlar − aynî borçlar (satış). Gizli varlık ve cariler yalnızca sahibinin net varlığına girer; gizli hesap bakiyeleri hane ayarına göre.
|
||||||
|
- Dashboard: "Toplam hesap bakiyesi", "Kart borçları" ve "Net varlık" kartları gerçek veriyle (gelir/gider kartları Faz 14).
|
||||||
|
- Aynî kayıt ↔ varlık bağlantısı: "Ödeme gerçekleşti → Aynı varlıkla" diyaloğunda isteğe bağlı kutucuk. Borç ödemesi seçilen varlıktan ortalama maliyetle düşer; alacak tahsilatı varlığa borç tarihindeki birim fiyatla eklenir. Ödeme ve varlık hareketi tek transaction'da; varlık yetersizse hiçbir şey yazılmaz. Bağlı hareket Birikim'den tek başına silinemez; aynî kayıt silinince bağ çözülür.
|
||||||
|
- Fiyat okuma yardımcıları ayrı modülde (`features/in-kind/prices.ts`).
|
||||||
|
- Testler: dönem noktaları, örnek senaryo ("10 gr'ı 25.000'e aldım, her ay ekledim"), başlangıç değeri, fiyat yokluğu, toplam (birim); dönem raporu, gizli varlık ve fiyatsız varlık, net varlık formülü ve değişmezleri (makas, cari borç), aynî borç/alacak bağlantısı, yetersiz varlıkta geri alma, tür uyuşmazlığı (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 9b
|
||||||
|
|
||||||
|
- "Aynı varlıkla" alacak tahsilatında anlamsız olan "varlığı almak için harcanan" tutar alanı kaldırıldı (yalnızca borç ödemesinde gösterilir).
|
||||||
|
- `recharts` bağımlılığı ve `components/ui/chart.tsx` eklendi.
|
||||||
|
- CHANGELOG sürüm karşılaştırma bağlantıları 0.6.0–0.9.0 için tamamlandı.
|
||||||
|
|
||||||
|
### Eklendi — Faz 9a: varlık lotları ve kâr/zarar
|
||||||
|
|
||||||
|
- Birikim sayfası (`/birikim`): altın, döviz ve ziynet varlıkları (`assets`); toplam güncel değer, toplam maliyet, gerçekleşmemiş ve gerçekleşmiş kâr/zarar; elde kalmayan varlıklar ayrı grupta. Fiyatı olmayan varlık toplam değere katılmaz ve belirtilir.
|
||||||
|
- Varlık hareketleri (`assetMovements`): alım, gelen hediye/miras, satış, hediye/çıkış; tarih, miktar, tutar veya birim fiyat (ikisi de boşsa fiyat tablosundan: alımda satış, satış ve hediyede alış fiyatı), hesap, saklama yeri, not. Geçmiş tarihli satış/çıkış o tarihteki miktarla sınırlıdır.
|
||||||
|
- Hesap bağlantısı: alım hesaptan varlığa, satış varlıktan hesaba transfer olarak kaydedilir (bakiye değişir, gelir/gider raporlarına girmez). İşlem listesinde varlık adıyla görünür; bağlı işlem tek başına düzenlenemez/silinemez, hareket silinince birlikte silinir.
|
||||||
|
- Hesap motoru (`shared/portfolio.ts`): ağırlıklı ortalama maliyet, satışta ortalama maliyetle gerçekleşmiş K/Z, çıkışta K/Z yazılmaz, gelen hediyenin maliyeti o günkü değer (yatırım sayılmaz); güncel değer alış fiyatıyla (Faz 0), gerçekleşmemiş K/Z TL ve %.
|
||||||
|
- `/birikim/:id`: özet (eldeki miktar, ortalama maliyet, maliyet, güncel değer, K/Z, yatırılan, satış geliri), hareket geçmişi (kalan miktar, satış K/Z'si), hareket silme (sonraki çıkışı eksiye düşürecekse reddedilir).
|
||||||
|
- "Yalnızca ben" varlık: varlık, hareketleri ve bağlı hesap işlemleri yalnızca sahibine görünür.
|
||||||
|
- Seed: aylık alımlı ve bir satışlı gram altın birikimi, düğün ziynetleri (hediye giriş ve çıkış), Emre'nin "yalnızca ben" dolar varlığı, Annemler'de fiyatsız bilezikler.
|
||||||
|
- `009` migration'ı: `assets`, `assetMovements` index'leri, işlemlerde `assetId`. Denetim kaydı: `asset.delete`, `asset.movement.delete`.
|
||||||
|
- Testler: ortalama maliyet, satış/çıkış, hediye, eksi miktar, oran biçimi (birim); alım/satış transferleri ve bakiye, fiyat tablosundan tutar, gerçekleşmiş K/Z, kilitli işlem, hareket silme, gizli varlık, izleyici ve başka hane (entegrasyon).
|
||||||
|
|
||||||
## [0.8.0] - 2026-11-05
|
## [0.8.0] - 2026-11-05
|
||||||
|
|
||||||
Faz 8: cari hesaplar ve aynî borç/alacak (8a ve 8b alt fazları).
|
Faz 8: cari hesaplar ve aynî borç/alacak (8a ve 8b alt fazları).
|
||||||
@@ -252,7 +284,11 @@ Faz 1: Framework, klasör yapısı ve altyapı.
|
|||||||
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
|
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
|
||||||
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
|
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
|
||||||
|
|
||||||
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.5.0...HEAD
|
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.9.0...HEAD
|
||||||
|
[0.9.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.8.0...v0.9.0
|
||||||
|
[0.8.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.7.0...v0.8.0
|
||||||
|
[0.7.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.6.0...v0.7.0
|
||||||
|
[0.6.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.5.0...v0.6.0
|
||||||
[0.5.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...v0.5.0
|
[0.5.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...v0.5.0
|
||||||
[0.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0
|
[0.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0
|
||||||
[0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0
|
[0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0
|
||||||
|
|||||||
Generated
+372
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.8.0",
|
"version": "0.9.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",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-hook-form": "^7.91.0",
|
"react-hook-form": "^7.91.0",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"shadcn": "^4.23.0",
|
"shadcn": "^4.23.0",
|
||||||
"sharp": "^0.35.5",
|
"sharp": "^0.35.5",
|
||||||
@@ -4051,6 +4052,46 @@
|
|||||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-m/PxMwRe0xLL6XUsAgy0YjInklHcfi9uMwFhJk+QINqjxqPvyc89T5cMjszA5bDmTE0gwGc7zr9LRO4gL+mhaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.2.9",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.2.9.tgz",
|
||||||
|
"integrity": "sha512-IQ4UEtXjZjyefyWjtLEDXUSrF6T2hwjKPi6BIJl3bY/2XXW24d6cNGdXJSBOA/+KNnuiXkZY6DR0C3efolhitA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||||
"version": "1.2.16",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
|
||||||
@@ -4964,6 +5005,69 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"assertion-error": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -5022,6 +5126,12 @@
|
|||||||
"@types/react": "^19.3.0"
|
"@types/react": "^19.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/validate-npm-package-name": {
|
"node_modules/@types/validate-npm-package-name": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||||
@@ -6573,6 +6683,127 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.5.tgz",
|
||||||
|
"integrity": "sha512-Th8qGXoBalmWsstpkuhmBuTsBQIFqYOJtjlBvNVvBfJ8E3uYusBUwUR8HU20U0qKKQkgCvKyR1YjiozWadk5QA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-lfab8x0Bj0UO7SNsY8dZSbNIA6ipbkE3X0NG9nqjCu6CfCHZseuDnOSJY7hidKrjpP++i1M2qT+FlAdPKYRilA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-B5znKqRmKROAN4oHWQ50W/qisVjA9GXh2WMsVKdFVp4DxWmcrOsweqgUOUVsv5th96kwaZlokU6SlV4J3gak1w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-+F+/49swqmGuQJmSKnVMIDH/QkEFV5li9QRj0xm/KhyAUBv099O5PrvpQFxBp24PYph17Mnvt9mjKQV0ii0nMA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-IRzGU62DtxhvsnSTWMQIdnoZ6gA+BCWwBon1EJI6SjoN595799b6z/QRBzXfFhE8iupUPp3042yBNo0STPqnzQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-jsbgV8zIT38ReB9O7X7gcLndTQRONSqNNd+oGIaTBdUug4r5jFmp0N6RY9R5GWbdUDucn0qoTGyfjapT4tDhcg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-66cWobCNzcMvlfoaDHCxuIv9TBUWG1BLOWdv9kHHFuCr1JdqAWPvTmsaEYDD5NRMyWZ5+7u0GqiCDfMXMqh3hg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-dm2o+lbKdzg9tY7GEag/dB9hcbodLfwkE/DkvqbELJXaMz0yKzWWhZqhbI7CKubukA7HnRyz0avdtvi7EPxPvw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-Ze7rjrhMzdeulh0/hjcfFj7XLAHOfowtEMfsoG6DU866HPynQNW7Wfen6hzXcqKTv8XzxvF6Hzr5XD6bMHr2ug==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-22XaqjyCjpqgda20erhsOjGmC3vvYwTNYniKAd45W5UWLDF43moeOuV9qYpRqobIlsnK0UhnKZjUUJm3VgORUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-1Il1aliroVSErrv5aulRBxAY+7+DnaoLIWGd1C6SsBTWDeobWiP/V69vwb8vT6abR1kKwaNe1196pmaQ1fK90A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -6666,6 +6897,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||||
@@ -7140,6 +7377,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.53.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.1.tgz",
|
||||||
|
"integrity": "sha512-VhMc6IXmexu8iYFfL2aaAww6IvJTdHlqBLruLoEUs33YD4IcP5DdZcLOybMsMmmLsiy1c8PsiLsrM0DdIuuRUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks",
|
||||||
|
"tests/types",
|
||||||
|
"tests/browser-compat"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.2",
|
"version": "0.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
@@ -7658,6 +7907,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/execa": {
|
"node_modules/execa": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
||||||
@@ -8282,6 +8537,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -8324,6 +8589,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.7.6",
|
"version": "10.7.6",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz",
|
||||||
@@ -10715,9 +10989,31 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.1.tgz",
|
||||||
|
"integrity": "sha512-vxMLT0lalUjbZPkoTepWyKAZbjIIFssNx/CEL9lTfwQBU6XpkGSmCNdcCIrkOBUgB68uftZMnbXVb6DwGnhRKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
@@ -10803,6 +11099,51 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||||
|
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -10856,6 +11197,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.7",
|
"version": "2.0.0-next.7",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||||
@@ -12405,6 +12752,28 @@
|
|||||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.4.1.tgz",
|
||||||
|
"integrity": "sha512-geV4p2GVfVM9lgm9ZRS3pvsbAPS+BUGhM9W9nPzPHTKi+/+KjNrFXdgP6KkG+XspNZuvcXmchcrtklfuQ4gniw==",
|
||||||
|
"license": "MIT AND ISC AND BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.3.5",
|
"version": "8.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.8.0",
|
"version": "0.9.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": {
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-hook-form": "^7.91.0",
|
"react-hook-form": "^7.91.0",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"shadcn": "^4.23.0",
|
"shadcn": "^4.23.0",
|
||||||
"sharp": "^0.35.5",
|
"sharp": "^0.35.5",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { AssetModel } from "@/models/asset";
|
||||||
|
import { AssetMovementModel } from "@/models/asset-movement";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "009-assets",
|
||||||
|
description: "assets ve assetMovements index'leri; işlemlerde varlık alanları",
|
||||||
|
async up(db) {
|
||||||
|
await db
|
||||||
|
.collection("transactions")
|
||||||
|
.updateMany({ assetId: { $exists: false } }, { $set: { assetId: null } });
|
||||||
|
await AssetModel.createIndexes();
|
||||||
|
await AssetMovementModel.createIndexes();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -8,10 +8,11 @@ import { migration as m005 } from "./005-recurring";
|
|||||||
import { migration as m006 } from "./006-card-statements";
|
import { migration as m006 } from "./006-card-statements";
|
||||||
import { migration as m007 } from "./007-contacts";
|
import { migration as m007 } from "./007-contacts";
|
||||||
import { migration as m008 } from "./008-in-kind";
|
import { migration as m008 } from "./008-in-kind";
|
||||||
|
import { migration as m009 } from "./009-assets";
|
||||||
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, m004, m005, m006, m007, m008];
|
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008, m009];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { Types } from "mongoose";
|
||||||
|
import { addMovement, createAsset } from "@/features/assets/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { AssetModel } from "@/models/asset";
|
||||||
|
import { AssetMovementModel } from "@/models/asset-movement";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { AssetKey } from "@/shared/assets";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import type { MovementKind } from "@/shared/portfolio";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Örnek varlıklar (Faz 9a). Tutarı boş alımlar fiyat tablosundaki örnek fiyatlardan (seedInKind
|
||||||
|
* önce çalışır) hesaplanır. İdempotent: varlık `seedRef` ile bir kez oluşturulur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type SeedMovement = {
|
||||||
|
kind: MovementKind;
|
||||||
|
daysAgo: number;
|
||||||
|
quantity: string;
|
||||||
|
amount?: string;
|
||||||
|
account?: string;
|
||||||
|
storage?: string;
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeedAsset = {
|
||||||
|
ref: string;
|
||||||
|
household: string;
|
||||||
|
by: string;
|
||||||
|
assetKey: AssetKey;
|
||||||
|
name: string;
|
||||||
|
isPrivate?: boolean;
|
||||||
|
note?: string;
|
||||||
|
movements: SeedMovement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthly = (from: number, to: number): SeedMovement[] =>
|
||||||
|
Array.from({ length: to - from + 1 }, (_, i) => ({
|
||||||
|
kind: "buy" as const,
|
||||||
|
daysAgo: (to - i) * 30 + 2,
|
||||||
|
quantity: "1",
|
||||||
|
account: "Ziraat Maaş",
|
||||||
|
storage: "Ev kasası",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ITEMS: SeedAsset[] = [
|
||||||
|
{
|
||||||
|
ref: "gram-altin",
|
||||||
|
household: "Çakır Ailesi",
|
||||||
|
by: "ayse",
|
||||||
|
assetKey: "gram_gold",
|
||||||
|
name: "Gram altın birikimi",
|
||||||
|
note: "Her ay maaştan 1 gr",
|
||||||
|
movements: [
|
||||||
|
{
|
||||||
|
kind: "buy",
|
||||||
|
daysAgo: 178,
|
||||||
|
quantity: "4",
|
||||||
|
account: "Birikim (Vadeli)",
|
||||||
|
storage: "Ev kasası",
|
||||||
|
note: "İlk alım",
|
||||||
|
},
|
||||||
|
...monthly(1, 5),
|
||||||
|
{ kind: "sell", daysAgo: 12, quantity: "2", account: "Birikim (Vadeli)", note: "Tatil için" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "ziynet",
|
||||||
|
household: "Çakır Ailesi",
|
||||||
|
by: "admin",
|
||||||
|
assetKey: "quarter_gold",
|
||||||
|
name: "Düğün ziynetleri",
|
||||||
|
movements: [
|
||||||
|
{
|
||||||
|
kind: "gift_in",
|
||||||
|
daysAgo: 175,
|
||||||
|
quantity: "6",
|
||||||
|
storage: "Banka kiralık kasa",
|
||||||
|
note: "Düğün takıları",
|
||||||
|
},
|
||||||
|
{ kind: "gift_out", daysAgo: 40, quantity: "1", note: "Yeğenin düğünü" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "emre-dolar",
|
||||||
|
household: "Çakır Ailesi",
|
||||||
|
by: "emre",
|
||||||
|
assetKey: "usd",
|
||||||
|
name: "Dolar",
|
||||||
|
isPrivate: true,
|
||||||
|
movements: [
|
||||||
|
{ kind: "buy", daysAgo: 120, quantity: "200", account: "Ziraat Maaş", storage: "Cüzdan" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "bilezik",
|
||||||
|
household: "Annemler",
|
||||||
|
by: "fatma",
|
||||||
|
assetKey: "bracelet_22k",
|
||||||
|
name: "Bilezikler",
|
||||||
|
note: "Fiyat tablosunda bilezik fiyatı yok (örnek: fiyatsız varlık)",
|
||||||
|
movements: [{ kind: "gift_in", daysAgo: 160, quantity: "40", amount: "120.000" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function seedAssets(
|
||||||
|
householdIds: Map<string, Types.ObjectId>,
|
||||||
|
userIds: Map<string, string>,
|
||||||
|
): Promise<number> {
|
||||||
|
const today = todayIstanbul();
|
||||||
|
let created = 0;
|
||||||
|
for (const x of ITEMS) {
|
||||||
|
const householdId = householdIds.get(x.household)!;
|
||||||
|
const seedRef = `asset:${x.ref}`;
|
||||||
|
if (await AssetModel.exists({ householdId, seedRef })) continue;
|
||||||
|
const by = userIds.get(x.by)!;
|
||||||
|
const hh = String(householdId);
|
||||||
|
const r = await createAsset(
|
||||||
|
by,
|
||||||
|
hh,
|
||||||
|
assetFormSchema.parse({
|
||||||
|
assetKey: x.assetKey,
|
||||||
|
name: x.name,
|
||||||
|
note: x.note ?? "",
|
||||||
|
isPrivate: x.isPrivate ?? false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!r.ok) throw new Error(`Seed varlığı oluşturulamadı (${x.ref}): ${r.error}`);
|
||||||
|
await AssetModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
|
||||||
|
for (const [i, m] of x.movements.entries()) {
|
||||||
|
const account = m.account
|
||||||
|
? String((await AccountModel.findOne({ householdId, name: m.account }).lean())!._id)
|
||||||
|
: "";
|
||||||
|
const res = await addMovement(
|
||||||
|
by,
|
||||||
|
hh,
|
||||||
|
r.data.id,
|
||||||
|
movementFormSchema.parse({
|
||||||
|
kind: m.kind,
|
||||||
|
date: addDays(today, -m.daysAgo),
|
||||||
|
quantity: m.quantity,
|
||||||
|
amount: m.amount ?? "",
|
||||||
|
accountId: account,
|
||||||
|
storage: m.storage ?? "",
|
||||||
|
note: m.note ?? "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`Seed varlık hareketi eklenemedi (${x.ref}): ${res.error}`);
|
||||||
|
const ref = `${seedRef}:${i}`;
|
||||||
|
const mv = await AssetMovementModel.findOneAndUpdate(
|
||||||
|
{ _id: res.data.id },
|
||||||
|
{ $set: { seedRef: ref } },
|
||||||
|
).lean();
|
||||||
|
if (mv?.transactionId)
|
||||||
|
await TransactionModel.updateOne({ _id: mv.transactionId }, { $set: { seedRef: ref } });
|
||||||
|
}
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ describe("seed", () => {
|
|||||||
expect(summary.created.categories).toBeGreaterThan(40);
|
expect(summary.created.categories).toBeGreaterThan(40);
|
||||||
expect(summary.created.accounts).toBe(7);
|
expect(summary.created.accounts).toBe(7);
|
||||||
expect(summary.created.transactions).toBeGreaterThan(150);
|
expect(summary.created.transactions).toBeGreaterThan(150);
|
||||||
|
expect(summary.created.assets).toBe(4);
|
||||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||||
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
||||||
});
|
});
|
||||||
@@ -49,6 +50,7 @@ describe("seed", () => {
|
|||||||
installmentPlans: 0,
|
installmentPlans: 0,
|
||||||
contacts: 0,
|
contacts: 0,
|
||||||
inKind: 0,
|
inKind: 0,
|
||||||
|
assets: 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);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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 { seedContacts } from "./contacts";
|
import { seedContacts } from "./contacts";
|
||||||
|
import { seedAssets } from "./assets";
|
||||||
import { seedInKind } from "./in-kind";
|
import { seedInKind } from "./in-kind";
|
||||||
import { seedInstallments } from "./installments";
|
import { seedInstallments } from "./installments";
|
||||||
import { seedRecurring } from "./recurring";
|
import { seedRecurring } from "./recurring";
|
||||||
@@ -51,6 +52,7 @@ export type SeedSummary = {
|
|||||||
installmentPlans: number;
|
installmentPlans: number;
|
||||||
contacts: number;
|
contacts: number;
|
||||||
inKind: number;
|
inKind: number;
|
||||||
|
assets: number;
|
||||||
};
|
};
|
||||||
accounts: SeedAccountRow[];
|
accounts: SeedAccountRow[];
|
||||||
};
|
};
|
||||||
@@ -71,6 +73,8 @@ const DOMAIN_COLLECTIONS = [
|
|||||||
"contacts",
|
"contacts",
|
||||||
"assetPrices",
|
"assetPrices",
|
||||||
"inKindDebts",
|
"inKindDebts",
|
||||||
|
"assets",
|
||||||
|
"assetMovements",
|
||||||
];
|
];
|
||||||
|
|
||||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||||
@@ -124,6 +128,7 @@ export async function runSeed({
|
|||||||
installmentPlans: 0,
|
installmentPlans: 0,
|
||||||
contacts: 0,
|
contacts: 0,
|
||||||
inKind: 0,
|
inKind: 0,
|
||||||
|
assets: 0,
|
||||||
},
|
},
|
||||||
accounts: [],
|
accounts: [],
|
||||||
};
|
};
|
||||||
@@ -302,6 +307,8 @@ export async function runSeed({
|
|||||||
if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`);
|
if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`);
|
||||||
summary.created.inKind = await seedInKind(householdIds, userIdMap);
|
summary.created.inKind = await seedInKind(householdIds, userIdMap);
|
||||||
if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`);
|
if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`);
|
||||||
|
summary.created.assets = await seedAssets(householdIds, userIdMap);
|
||||||
|
if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`);
|
||||||
|
|
||||||
// 7) Geliştirme hesapları tablosu
|
// 7) Geliştirme hesapları tablosu
|
||||||
const all = [
|
const all = [
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { AssetDetailView } from "@/features/assets/components/asset-detail-view";
|
||||||
|
import { PeriodReportView } from "@/features/assets/components/period-report";
|
||||||
|
import {
|
||||||
|
getAsset,
|
||||||
|
getPeriodReport,
|
||||||
|
REPORT_RANGES,
|
||||||
|
type ReportRange,
|
||||||
|
} from "@/features/assets/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.assets.history };
|
||||||
|
|
||||||
|
/** /birikim/:id — varlık hareketleri ve dönemsel rapor (gizli varlık yalnızca sahibine; aksi 404). */
|
||||||
|
export default async function AssetPage({ params, searchParams }: PageProps<"/birikim/[id]">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const { id } = await params;
|
||||||
|
const donem = (await searchParams).donem;
|
||||||
|
const range: ReportRange = REPORT_RANGES.includes(donem as ReportRange)
|
||||||
|
? (donem as ReportRange)
|
||||||
|
: "6";
|
||||||
|
const [data, report] = await Promise.all([
|
||||||
|
getAsset(user.id, household.id, id),
|
||||||
|
getPeriodReport(user.id, household.id, range, id),
|
||||||
|
]);
|
||||||
|
if (!data || !report) notFound();
|
||||||
|
return (
|
||||||
|
<AssetDetailView
|
||||||
|
key={id}
|
||||||
|
data={data}
|
||||||
|
report={<PeriodReportView report={report} range={range} basePath={`/birikim/${id}`} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { AssetsView } from "@/features/assets/components/assets-view";
|
||||||
|
import { PeriodReportView } from "@/features/assets/components/period-report";
|
||||||
|
import {
|
||||||
|
getPeriodReport,
|
||||||
|
listAssets,
|
||||||
|
REPORT_RANGES,
|
||||||
|
type ReportRange,
|
||||||
|
} from "@/features/assets/service";
|
||||||
|
import { NetWorthCard } from "@/features/net-worth/components/net-worth-card";
|
||||||
|
import { getNetWorth } from "@/features/net-worth/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.assets.title };
|
||||||
|
|
||||||
|
function parseRange(value: string | string[] | undefined): ReportRange {
|
||||||
|
return REPORT_RANGES.includes(value as ReportRange) ? (value as ReportRange) : "6";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /birikim — net varlık, varlıklar (ortalama maliyet, K/Z) ve dönemsel rapor. */
|
||||||
|
export default async function AssetsPage({ searchParams }: PageProps<"/birikim">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const range = parseRange((await searchParams).donem);
|
||||||
|
const [data, report, netWorth] = await Promise.all([
|
||||||
|
listAssets(user.id, household.id),
|
||||||
|
getPeriodReport(user.id, household.id, range),
|
||||||
|
getNetWorth(user.id, household.id),
|
||||||
|
]);
|
||||||
|
if (!data || !report || !netWorth) notFound();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title={tr.assets.title} description={tr.assets.description} />
|
||||||
|
<section aria-labelledby="net-worth-title" className="flex flex-col gap-2">
|
||||||
|
<h2 id="net-worth-title" className="text-base font-medium">
|
||||||
|
{tr.assets.netWorth.title}
|
||||||
|
</h2>
|
||||||
|
<NetWorthCard data={netWorth} />
|
||||||
|
</section>
|
||||||
|
<AssetsView items={data.items} totals={data.totals} canWrite={data.canWrite} />
|
||||||
|
<PeriodReportView report={report} range={range} basePath="/birikim" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
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 { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
||||||
|
import { getNetWorth } from "@/features/net-worth/service";
|
||||||
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
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";
|
||||||
@@ -16,22 +18,27 @@ type SummaryCard = {
|
|||||||
key: keyof typeof tr.dashboard.cards;
|
key: keyof typeof tr.dashboard.cards;
|
||||||
tone?: string;
|
tone?: string;
|
||||||
amountMinor: number | null;
|
amountMinor: number | null;
|
||||||
|
href?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** FAZ 14: gerçek özet verisi (gelir, gider, bakiye, kart borcu, net varlık). */
|
/**
|
||||||
async function getSummary(): Promise<SummaryCard[]> {
|
* Özet kartları. Bakiye, kart borcu ve net varlık gerçek (Faz 9); bu ayın gelir/gider/kalan
|
||||||
|
* kartları Faz 14'te dolar.
|
||||||
|
*/
|
||||||
|
async function getSummary(userId: string, householdId: string): Promise<SummaryCard[]> {
|
||||||
|
const nw = await getNetWorth(userId, householdId);
|
||||||
return [
|
return [
|
||||||
{ key: "monthIncome", tone: "text-income", amountMinor: null },
|
{ key: "monthIncome", tone: "text-income", amountMinor: null },
|
||||||
{ key: "monthExpense", tone: "text-expense", amountMinor: null },
|
{ key: "monthExpense", tone: "text-expense", amountMinor: null },
|
||||||
{ key: "monthRemaining", tone: "text-budget", amountMinor: null },
|
{ key: "monthRemaining", tone: "text-budget", amountMinor: null },
|
||||||
{ key: "totalBalance", amountMinor: null },
|
{ key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" },
|
||||||
{ key: "cardDebt", tone: "text-expense", amountMinor: null },
|
{ key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null },
|
||||||
{ key: "netWorth", amountMinor: null },
|
{ key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function SummaryCards() {
|
async function SummaryCards({ userId, householdId }: { userId: string; householdId: string }) {
|
||||||
const cards = await getSummary();
|
const cards = await getSummary(userId, householdId);
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
{cards.map((c) => (
|
{cards.map((c) => (
|
||||||
@@ -39,7 +46,15 @@ async function SummaryCards() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardDescription>{tr.dashboard.cards[c.key]}</CardDescription>
|
<CardDescription>{tr.dashboard.cards[c.key]}</CardDescription>
|
||||||
<CardTitle className={cn("text-lg tabular-nums", c.tone)}>
|
<CardTitle className={cn("text-lg tabular-nums", c.tone)}>
|
||||||
{c.amountMinor === null ? "—" : formatMoney(c.amountMinor)}
|
{c.amountMinor === null ? (
|
||||||
|
"—"
|
||||||
|
) : c.href ? (
|
||||||
|
<Link href={c.href} className="hover:underline">
|
||||||
|
{formatMoney(c.amountMinor)}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
formatMoney(c.amountMinor)
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{c.amountMinor === null ? (
|
{c.amountMinor === null ? (
|
||||||
@@ -68,7 +83,7 @@ export default async function DashboardPage() {
|
|||||||
<ContactsDueBanner userId={user.id} householdId={household.id} />
|
<ContactsDueBanner userId={user.id} householdId={household.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||||
<SummaryCards />
|
<SummaryCards userId={user.id} householdId={household.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 = 8.2;
|
export const CURRENT_PHASE = 9.2;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export const MAIN_NAV: NavItem[] = [
|
|||||||
{ 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.1 },
|
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 },
|
||||||
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
|
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
|
||||||
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 },
|
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 },
|
||||||
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
|
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
|
||||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
|
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
|
||||||
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
|
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as RechartsPrimitive from "recharts";
|
||||||
|
import type { TooltipValueType } from "recharts";
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
|
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
|
||||||
|
type TooltipNameType = number | string;
|
||||||
|
|
||||||
|
export type ChartConfig = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
initialDimension = INITIAL_DIMENSION,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||||
|
initialDimension?: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: "line" | "dot" | "dashed";
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
} & Omit<
|
||||||
|
RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>,
|
||||||
|
"accessibilityLayer"
|
||||||
|
>) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string" ? (config[label]?.label ?? label) : itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||||
|
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor = color ?? item.payload?.fill ?? item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
|
indicator === "dot" && "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label ?? item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value != null && (
|
||||||
|
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||||
|
{typeof item.value === "number"
|
||||||
|
? item.value.toLocaleString()
|
||||||
|
: String(item.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.dataKey ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"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 { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||||
|
import {
|
||||||
|
addMovement,
|
||||||
|
createAsset,
|
||||||
|
deleteAsset,
|
||||||
|
deleteMovement,
|
||||||
|
listAssets,
|
||||||
|
updateAsset,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run<T>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
input: unknown,
|
||||||
|
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<unknown>>,
|
||||||
|
): Promise<ActionResult<unknown>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const parsed = schema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||||
|
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||||
|
if (result.ok) revalidatePath("/", "layout");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yeni varlık (assetId boş) veya mevcut varlığı düzenle. */
|
||||||
|
export async function saveAssetAction(assetId: unknown, input: unknown) {
|
||||||
|
if (assetId) {
|
||||||
|
const id = idSchema.safeParse(assetId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(assetFormSchema, input, (u, h, d) => updateAsset(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
return run(assetFormSchema, input, createAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAssetAction(assetId: unknown) {
|
||||||
|
const id = idSchema.safeParse(assetId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => deleteAsset(u, h, id.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMovementAction(assetId: unknown, input: unknown) {
|
||||||
|
const id = idSchema.safeParse(assetId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(movementFormSchema, input, (u, h, d) => addMovement(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMovementAction(movementId: unknown) {
|
||||||
|
const id = idSchema.safeParse(movementId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => deleteMovement(u, h, id.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssetOption = { id: string; name: string; assetKey: string; quantity: number };
|
||||||
|
|
||||||
|
/** Aynî ödeme diyaloğu için görünür varlıklar (ad, tür, eldeki miktar). */
|
||||||
|
export async function getAssetOptionsAction(): Promise<ActionResult<AssetOption[]>> {
|
||||||
|
const ctx = await getHouseholdActionContext("viewer");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const data = await listAssets(ctx.user.id, ctx.household.id);
|
||||||
|
if (!data) return fail(tr.common.unauthorized);
|
||||||
|
return ok(
|
||||||
|
data.items.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
assetKey: a.assetKey,
|
||||||
|
quantity: a.summary.quantity,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
EllipsisVertical,
|
||||||
|
Gift,
|
||||||
|
HandCoins,
|
||||||
|
Lock,
|
||||||
|
Pencil,
|
||||||
|
ShoppingBag,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
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, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { ASSETS, formatAssetQuantity } from "@/shared/assets";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { isInflow, type MovementKind } from "@/shared/portfolio";
|
||||||
|
import { deleteAssetAction, deleteMovementAction } from "../actions";
|
||||||
|
import type { AssetDetail, MovementRow } from "../service";
|
||||||
|
import { AssetFormDialog, MovementDialog } from "./asset-dialogs";
|
||||||
|
import { ProfitLoss, Stat } from "./assets-view";
|
||||||
|
|
||||||
|
const t = tr.assets;
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
|
||||||
|
type Pending =
|
||||||
|
| { kind: "edit" }
|
||||||
|
| { kind: "movement"; movement: MovementKind }
|
||||||
|
| { kind: "deleteMovement"; row: MovementRow }
|
||||||
|
| { kind: "deleteAsset" }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
/** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */
|
||||||
|
export function AssetDetailView({
|
||||||
|
data,
|
||||||
|
report,
|
||||||
|
}: {
|
||||||
|
data: AssetDetail;
|
||||||
|
/** Dönemsel rapor (sunucuda hazırlanır). */
|
||||||
|
report?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState<Pending>(null);
|
||||||
|
const close = () => setPending(null);
|
||||||
|
const s = data.summary;
|
||||||
|
const unit = ASSETS[data.assetKey].unit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href="/birikim">
|
||||||
|
<ArrowLeft />
|
||||||
|
{t.back}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<PageHeader
|
||||||
|
title={data.name}
|
||||||
|
description={ASSETS[data.assetKey].label}
|
||||||
|
actions={
|
||||||
|
data.canWrite ? (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="icon" variant="outline" aria-label={tr.recurring.actions.more}>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={() => setPending({ kind: "edit" })}>
|
||||||
|
<Pencil />
|
||||||
|
{tr.common.edit}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
disabled={data.movementCount > 0}
|
||||||
|
onSelect={() => setPending({ kind: "deleteAsset" })}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
{t.delete}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{data.isPrivate ? (
|
||||||
|
<Badge variant="outline" className="-mt-2 w-fit gap-1">
|
||||||
|
<Lock className="size-3" />
|
||||||
|
{t.privateBadge}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card size="sm">
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label={t.stats.quantity}>{formatAssetQuantity(data.assetKey, s.quantity)}</Stat>
|
||||||
|
<Stat label={t.stats.avgCost}>
|
||||||
|
{s.avgCostMinor !== null
|
||||||
|
? `${formatMoney(s.avgCostMinor)} ${t.stats.perUnit(unit)}`
|
||||||
|
: "—"}
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.stats.cost}>{formatMoney(s.costMinor)}</Stat>
|
||||||
|
<Stat label={t.stats.currentValue}>
|
||||||
|
{s.currentValueMinor !== null ? formatMoney(s.currentValueMinor) : t.stats.noPrice}
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.stats.unrealized}>
|
||||||
|
<ProfitLoss minor={s.unrealizedMinor} bp={s.unrealizedBp} />
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.stats.realized}>
|
||||||
|
<ProfitLoss minor={s.realizedMinor} />
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.stats.invested}>{formatMoney(s.investedMinor)}</Stat>
|
||||||
|
<Stat label={t.stats.proceeds}>{formatMoney(s.proceedsMinor)}</Stat>
|
||||||
|
</div>
|
||||||
|
{data.price ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.stats.price(formatMoney(data.price.buyMinor), d(data.price.date))} ·{" "}
|
||||||
|
<Link href="/fiyatlar" className="underline-offset-2 hover:underline">
|
||||||
|
{t.prices}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{data.storage ? (
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{t.movement.storage}:</span> {data.storage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{data.note ? <p className="text-sm text-muted-foreground">{data.note}</p> : null}
|
||||||
|
{data.canWrite ? (
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPending({ kind: "movement", movement: "gift_in" })}
|
||||||
|
>
|
||||||
|
<Gift />
|
||||||
|
{t.kinds.gift_in}
|
||||||
|
</Button>
|
||||||
|
{s.quantity > 0 ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPending({ kind: "movement", movement: "sell" })}
|
||||||
|
>
|
||||||
|
<HandCoins />
|
||||||
|
{t.kinds.sell}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button size="sm" onClick={() => setPending({ kind: "movement", movement: "buy" })}>
|
||||||
|
<ShoppingBag />
|
||||||
|
{t.kinds.buy}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{report}
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-2" aria-labelledby="movements-title">
|
||||||
|
<h2 id="movements-title" className="text-base font-medium">
|
||||||
|
{t.history}
|
||||||
|
</h2>
|
||||||
|
{data.movements.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.noMovements}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{data.movements.map((row) => (
|
||||||
|
<MovementItem
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
data={data}
|
||||||
|
canWrite={data.canWrite}
|
||||||
|
onDelete={() => setPending({ kind: "deleteMovement", row })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{pending?.kind === "edit" ? <AssetFormDialog item={data} onClose={close} /> : null}
|
||||||
|
{pending?.kind === "movement" ? (
|
||||||
|
<MovementDialog item={data} initialKind={pending.movement} onClose={close} />
|
||||||
|
) : null}
|
||||||
|
<AlertDialog
|
||||||
|
open={pending?.kind === "deleteMovement" || pending?.kind === "deleteAsset"}
|
||||||
|
onOpenChange={(o) => !o && close()}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{pending?.kind === "deleteAsset" ? t.deleteTitle : t.deleteMovementTitle}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{pending?.kind === "deleteAsset" ? t.deleteDescription : t.deleteMovementDescription}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
if (pending?.kind === "deleteAsset") {
|
||||||
|
const r = await deleteAssetAction(data.id);
|
||||||
|
close();
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.deleted);
|
||||||
|
router.push("/birikim");
|
||||||
|
} else toast.error(r.error);
|
||||||
|
} else if (pending?.kind === "deleteMovement") {
|
||||||
|
const r = await deleteMovementAction(pending.row.id);
|
||||||
|
close();
|
||||||
|
if (r.ok) toast.success(t.toast.movementDeleted);
|
||||||
|
else toast.error(r.error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pending?.kind === "deleteAsset" ? t.delete : t.deleteMovement}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MovementItem({
|
||||||
|
row,
|
||||||
|
data,
|
||||||
|
canWrite,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
row: MovementRow;
|
||||||
|
data: AssetDetail;
|
||||||
|
canWrite: boolean;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const inflow = isInflow(row.kind);
|
||||||
|
const qty = formatAssetQuantity(data.assetKey, row.quantity);
|
||||||
|
const unit = ASSETS[data.assetKey].unit;
|
||||||
|
return (
|
||||||
|
<li className="flex items-start gap-3 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="flex flex-wrap items-center gap-x-2 text-sm font-medium">
|
||||||
|
<span>{t.kinds[row.kind]}</span>
|
||||||
|
<span className={inflow ? "text-income" : "text-expense"}>
|
||||||
|
{inflow ? "+" : "−"}
|
||||||
|
{qty}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{d(row.date)}
|
||||||
|
{row.unitPriceMinor !== null
|
||||||
|
? ` · ${formatMoney(row.unitPriceMinor)} ${t.stats.perUnit(unit)}`
|
||||||
|
: ""}
|
||||||
|
{row.account ? ` · ${row.account}` : ""}
|
||||||
|
{row.storage ? ` · ${row.storage}` : ""}
|
||||||
|
</p>
|
||||||
|
{row.note ? <p className="text-xs text-muted-foreground">{row.note}</p> : null}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.table.balance}: {formatAssetQuantity(data.assetKey, row.quantityAfter)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end text-sm">
|
||||||
|
{row.kind !== "gift_out" ? (
|
||||||
|
<span className="tabular-nums">{formatMoney(row.amountMinor)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{t.stats.cost} {formatMoney(row.costOutMinor)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.kind === "sell" ? (
|
||||||
|
<span className="text-xs">
|
||||||
|
{t.table.result}: <ProfitLoss minor={row.realizedMinor} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{canWrite && !row.fromInKind ? (
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t.deleteMovement}
|
||||||
|
onClick={onDelete}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useAccounts } from "@/features/in-kind/components/in-kind-section";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { ASSET_KEYS, ASSETS, formatAssetQuantity, type AssetKey } from "@/shared/assets";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { MOVEMENT_KINDS, type MovementKind } from "@/shared/portfolio";
|
||||||
|
import { addMovementAction, saveAssetAction } from "../actions";
|
||||||
|
import type { AssetView } from "../service";
|
||||||
|
|
||||||
|
const t = tr.assets;
|
||||||
|
type Errors = Record<string, string | undefined>;
|
||||||
|
const NONE = "__none__";
|
||||||
|
|
||||||
|
function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form={formId} disabled={busy}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yeni varlık / varlığı düzenle. */
|
||||||
|
export function AssetFormDialog({
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
item: AssetView | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated?: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [v, setV] = useState({
|
||||||
|
assetKey: (item?.assetKey ?? "gram_gold") as AssetKey,
|
||||||
|
name: item?.name ?? "",
|
||||||
|
note: item?.note ?? "",
|
||||||
|
isPrivate: item?.isPrivate ?? false,
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Errors>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||||
|
const locked = Boolean(item?.movementCount);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await saveAssetAction(item?.id ?? null, {
|
||||||
|
...v,
|
||||||
|
name: v.name.trim() || ASSETS[v.assetKey].label,
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.saved);
|
||||||
|
onClose();
|
||||||
|
if (!item) onCreated?.((r.data as { id: string }).id);
|
||||||
|
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const f = t.fields;
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={item ? t.editTitle : t.createTitle}
|
||||||
|
footer={<Footer busy={busy} formId="asset-form" onClose={onClose} />}
|
||||||
|
>
|
||||||
|
<form id="asset-form" onSubmit={submit} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={Boolean(errors.assetKey)}>
|
||||||
|
<FieldLabel htmlFor="as-key">{f.asset}</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={v.assetKey}
|
||||||
|
onValueChange={(x) => set("assetKey", x as AssetKey)}
|
||||||
|
disabled={locked}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="as-key" className="w-full">
|
||||||
|
<SelectValue>{ASSETS[v.assetKey].label}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ASSET_KEYS.map((k) => (
|
||||||
|
<SelectItem key={k} value={k}>
|
||||||
|
{ASSETS[k].label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.assetKey ? <FieldError>{errors.assetKey}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.name)}>
|
||||||
|
<FieldLabel htmlFor="as-name">{f.name}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="as-name"
|
||||||
|
value={v.name}
|
||||||
|
placeholder={ASSETS[v.assetKey].label}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.name)}
|
||||||
|
/>
|
||||||
|
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="as-note">{f.note}</FieldLabel>
|
||||||
|
<Input id="as-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={v.isPrivate}
|
||||||
|
onCheckedChange={(x) => set("isPrivate", x === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{f.isPrivate}
|
||||||
|
<span className="block text-xs text-muted-foreground">{f.isPrivateHint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alım, gelen hediye, satış veya çıkış hareketi. */
|
||||||
|
export function MovementDialog({
|
||||||
|
item,
|
||||||
|
initialKind,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
item: AssetView;
|
||||||
|
initialKind: MovementKind;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const accounts = useAccounts();
|
||||||
|
const [kind, setKind] = useState<MovementKind>(initialKind);
|
||||||
|
const [v, setV] = useState({
|
||||||
|
date: todayIstanbul(),
|
||||||
|
quantity: "",
|
||||||
|
amount: "",
|
||||||
|
unitPrice: "",
|
||||||
|
accountId: "",
|
||||||
|
storage: item.storage,
|
||||||
|
note: "",
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Errors>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||||
|
const withAccount = kind === "buy" || kind === "sell";
|
||||||
|
const priced = kind !== "gift_out";
|
||||||
|
const inflow = kind === "buy" || kind === "gift_in";
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await addMovementAction(item.id, {
|
||||||
|
kind,
|
||||||
|
date: v.date,
|
||||||
|
quantity: v.quantity,
|
||||||
|
amount: priced ? v.amount : "",
|
||||||
|
unitPrice: priced ? v.unitPrice : "",
|
||||||
|
accountId: withAccount ? v.accountId : "",
|
||||||
|
storage: inflow ? v.storage : "",
|
||||||
|
note: v.note,
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.movement);
|
||||||
|
onClose();
|
||||||
|
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = t.movement;
|
||||||
|
const unit = ASSETS[item.assetKey].unit;
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={m.title(t.kinds[kind], item.name)}
|
||||||
|
description={m.held(formatAssetQuantity(item.assetKey, item.summary.quantity))}
|
||||||
|
footer={<Footer busy={busy} formId="movement-form" onClose={onClose} />}
|
||||||
|
>
|
||||||
|
<form id="movement-form" onSubmit={submit} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
<Tabs
|
||||||
|
value={kind}
|
||||||
|
onValueChange={(x) => {
|
||||||
|
setKind(x as MovementKind);
|
||||||
|
setErrors({});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabsList className="grid h-auto w-full grid-cols-2 sm:grid-cols-4">
|
||||||
|
{MOVEMENT_KINDS.map((k) => (
|
||||||
|
<TabsTrigger key={k} value={k}>
|
||||||
|
{t.kinds[k]}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<p className="-mt-2 text-xs text-muted-foreground">{t.kindHints[kind]}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.quantity)}>
|
||||||
|
<FieldLabel htmlFor="mv-qty">
|
||||||
|
{m.quantity} ({unit})
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="mv-qty"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={v.quantity}
|
||||||
|
onChange={(e) => set("quantity", e.target.value)}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.quantity)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.date)}>
|
||||||
|
<FieldLabel htmlFor="mv-date">{m.date}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="mv-date"
|
||||||
|
type="date"
|
||||||
|
value={v.date}
|
||||||
|
max={todayIstanbul()}
|
||||||
|
onChange={(e) => set("date", e.target.value)}
|
||||||
|
/>
|
||||||
|
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{priced ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.amount)}>
|
||||||
|
<FieldLabel htmlFor="mv-amount">{m.amount}</FieldLabel>
|
||||||
|
<MoneyInput id="mv-amount" value={v.amount} onChange={(x) => set("amount", x)} />
|
||||||
|
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.unitPrice)}>
|
||||||
|
<FieldLabel htmlFor="mv-unit">{m.unitPrice}</FieldLabel>
|
||||||
|
<MoneyInput
|
||||||
|
id="mv-unit"
|
||||||
|
value={v.unitPrice}
|
||||||
|
onChange={(x) => set("unitPrice", x)}
|
||||||
|
/>
|
||||||
|
{errors.unitPrice ? <FieldError>{errors.unitPrice}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<FieldDescription className="-mt-2">
|
||||||
|
{kind === "gift_in" ? m.giftValueHint : m.priceHint}
|
||||||
|
</FieldDescription>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{withAccount ? (
|
||||||
|
<Field data-invalid={Boolean(errors.accountId)}>
|
||||||
|
<FieldLabel htmlFor="mv-account">{m.account}</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={v.accountId || NONE}
|
||||||
|
onValueChange={(x) => set("accountId", x === NONE ? "" : x)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="mv-account" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{accounts?.find((a) => a.id === v.accountId)?.name ?? m.accountNone}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
<SelectItem value={NONE}>{m.accountNone}</SelectItem>
|
||||||
|
{(accounts ?? []).map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldDescription>{m.accountHint}</FieldDescription>
|
||||||
|
{errors.accountId ? <FieldError>{errors.accountId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
{inflow ? (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="mv-storage">{m.storage}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="mv-storage"
|
||||||
|
value={v.storage}
|
||||||
|
placeholder={m.storagePlaceholder}
|
||||||
|
onChange={(e) => set("storage", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="mv-note">{m.note}</FieldLabel>
|
||||||
|
<Input id="mv-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronRight, Coins, Lock, Plus } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { formatBp } from "@/shared/portfolio";
|
||||||
|
import type { AssetTotals, AssetView } from "../service";
|
||||||
|
import { AssetFormDialog } from "./asset-dialogs";
|
||||||
|
|
||||||
|
const t = tr.assets;
|
||||||
|
|
||||||
|
/** Kâr/zarar tutarı: + yeşil, − kırmızı. */
|
||||||
|
export function ProfitLoss({
|
||||||
|
minor,
|
||||||
|
bp,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
minor: number | null;
|
||||||
|
bp?: number | null;
|
||||||
|
/** Mobilde oranı gizle (dar satırlar). */
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
if (minor === null) return <span className="text-muted-foreground">—</span>;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-semibold whitespace-nowrap tabular-nums",
|
||||||
|
minor > 0 ? "text-income" : minor < 0 ? "text-expense" : undefined,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{minor > 0 ? "+" : minor < 0 ? "−" : ""}
|
||||||
|
{formatMoney(Math.abs(minor))}
|
||||||
|
{bp !== undefined && bp !== null ? (
|
||||||
|
<span className={cn("ml-1 text-xs font-normal", compact && "hidden sm:inline")}>
|
||||||
|
({formatBp(bp)})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Stat({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<div className="font-semibold tabular-nums">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /birikim: toplamlar ve varlık kartları. */
|
||||||
|
export function AssetsView({
|
||||||
|
items,
|
||||||
|
totals,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
items: AssetView[];
|
||||||
|
totals: AssetTotals;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const held = items.filter((i) => i.summary.quantity > 0 || i.movementCount === 0);
|
||||||
|
const closed = items.filter((i) => i.summary.quantity === 0 && i.movementCount > 0);
|
||||||
|
const unrealizedBp =
|
||||||
|
totals.costMinor > 0 ? Math.round((totals.unrealizedMinor * 10_000) / totals.costMinor) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/fiyatlar">
|
||||||
|
<Coins />
|
||||||
|
{t.prices}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{canWrite ? (
|
||||||
|
<Button size="sm" onClick={() => setCreating(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.new}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card size="sm">
|
||||||
|
<CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label={t.totals.value}>{formatMoney(totals.valueMinor)}</Stat>
|
||||||
|
<Stat label={t.totals.cost}>{formatMoney(totals.costMinor)}</Stat>
|
||||||
|
<Stat label={t.totals.unrealized}>
|
||||||
|
<ProfitLoss minor={totals.unrealizedMinor} bp={unrealizedBp} />
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.totals.realized}>
|
||||||
|
<ProfitLoss minor={totals.realizedMinor} />
|
||||||
|
</Stat>
|
||||||
|
{totals.missingPrice ? (
|
||||||
|
<p className="col-span-full text-xs text-muted-foreground">
|
||||||
|
{t.totals.missingPrice(totals.missingPrice)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.empty}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{held.map((item) => (
|
||||||
|
<AssetRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{closed.length ? (
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground">{t.closedGroup}</h2>
|
||||||
|
<ul className="flex flex-col gap-2 opacity-70">
|
||||||
|
{closed.map((item) => (
|
||||||
|
<AssetRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{creating ? (
|
||||||
|
<AssetFormDialog
|
||||||
|
item={null}
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
onCreated={(id) => router.push(`/birikim/${id}`)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssetRow({ item }: { item: AssetView }) {
|
||||||
|
const s = item.summary;
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/birikim/${item.id}`}
|
||||||
|
className="flex items-center gap-3 rounded-xl border bg-card p-3 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
|
||||||
|
<Coins className="size-4.5" />
|
||||||
|
</span>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="flex min-w-0 items-center gap-1.5 font-medium">
|
||||||
|
<span className="truncate">{item.name}</span>
|
||||||
|
{item.isPrivate ? (
|
||||||
|
<Badge variant="outline" className="shrink-0 gap-1" title={t.privateBadge}>
|
||||||
|
<Lock className="size-3" />
|
||||||
|
<span className="hidden sm:inline">{t.privateBadge}</span>
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<span className="shrink-0 text-sm font-semibold tabular-nums">
|
||||||
|
{s.currentValueMinor !== null ? formatMoney(s.currentValueMinor) : t.stats.noPrice}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<p className="min-w-0 truncate text-muted-foreground">
|
||||||
|
{item.label}
|
||||||
|
{s.avgCostMinor !== null
|
||||||
|
? ` · ${t.stats.avgCost} ${formatMoney(s.avgCostMinor)}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
<span className="shrink-0">
|
||||||
|
{s.quantity > 0 ? (
|
||||||
|
<ProfitLoss minor={s.unrealizedMinor} bp={s.unrealizedBp} compact />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t.stats.realized}: <ProfitLoss minor={s.realizedMinor} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import type { PeriodReport } from "@/shared/portfolio";
|
||||||
|
import type { ReportRange } from "../service";
|
||||||
|
import { ProfitLoss, Stat } from "./assets-view";
|
||||||
|
|
||||||
|
const t = tr.assets.report;
|
||||||
|
const RANGES: ReportRange[] = ["3", "6", "12", "tum"];
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
const tickFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
});
|
||||||
|
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
value: { label: t.value, color: "var(--budget)" },
|
||||||
|
cost: { label: t.cost, color: "var(--muted-foreground)" },
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
/** Dönem seçici + özet + aylık değer/maliyet grafiği + tablo görünümü. */
|
||||||
|
export function PeriodReportView({
|
||||||
|
report,
|
||||||
|
range,
|
||||||
|
basePath,
|
||||||
|
}: {
|
||||||
|
report: PeriodReport & { excluded: number };
|
||||||
|
range: ReportRange;
|
||||||
|
basePath: string;
|
||||||
|
}) {
|
||||||
|
const data = report.points.map((p) => ({
|
||||||
|
date: p.date,
|
||||||
|
label: tickFmt.format(new Date(`${p.date}T12:00:00+03:00`)),
|
||||||
|
// Kuruş → TL (eksen ve ipucu TL gösterir).
|
||||||
|
value: p.valueMinor === null ? null : p.valueMinor / 100,
|
||||||
|
cost: p.costMinor / 100,
|
||||||
|
}));
|
||||||
|
const hasData = report.points.some((p) => p.costMinor > 0 || (p.valueMinor ?? 0) > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||||
|
<CardDescription>{t.period(d(report.from), d(report.to))}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<nav aria-label={t.rangeLabel} className="flex rounded-lg bg-muted p-0.5 text-sm">
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<Link
|
||||||
|
key={r}
|
||||||
|
href={`${basePath}?donem=${r}`}
|
||||||
|
scroll={false}
|
||||||
|
aria-current={r === range ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-2.5 py-1 text-muted-foreground transition-colors",
|
||||||
|
r === range && "bg-background font-medium text-foreground shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.ranges[r]}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label={t.startValue}>
|
||||||
|
{report.startValueMinor !== null ? formatMoney(report.startValueMinor) : t.noPrice}
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.invested}>{formatMoney(report.investedMinor)}</Stat>
|
||||||
|
<Stat label={t.endValue}>
|
||||||
|
{report.endValueMinor !== null ? formatMoney(report.endValueMinor) : t.noPrice}
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.profit}>
|
||||||
|
<ProfitLoss minor={report.profitMinor} bp={report.profitBp} />
|
||||||
|
</Stat>
|
||||||
|
{report.proceedsMinor ? (
|
||||||
|
<Stat label={t.proceeds}>{formatMoney(report.proceedsMinor)}</Stat>
|
||||||
|
) : null}
|
||||||
|
{report.giftInMinor ? (
|
||||||
|
<Stat label={t.giftIn}>{formatMoney(report.giftInMinor)}</Stat>
|
||||||
|
) : null}
|
||||||
|
{report.giftOutMinor ? (
|
||||||
|
<Stat label={t.giftOut}>{formatMoney(report.giftOutMinor)}</Stat>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t.formula}</p>
|
||||||
|
{report.excluded ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.excluded(report.excluded)}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasData ? (
|
||||||
|
<>
|
||||||
|
<ChartContainer
|
||||||
|
config={config}
|
||||||
|
className="aspect-auto h-56 w-full sm:h-64"
|
||||||
|
aria-label={t.chartLabel}
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<LineChart data={data} margin={{ left: 4, right: 12, top: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={16}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={48}
|
||||||
|
tickFormatter={(v: number) => compact.format(v)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const date = payload?.[0]?.payload?.date as string | undefined;
|
||||||
|
return date ? d(date) : "";
|
||||||
|
}}
|
||||||
|
formatter={(value, name) => (
|
||||||
|
<div className="flex w-full items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{config[name as keyof typeof config]?.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
|
{formatMoney(Math.round(Number(value) * 100))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
<Line
|
||||||
|
dataKey="cost"
|
||||||
|
type="stepAfter"
|
||||||
|
stroke="var(--color-cost)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="value"
|
||||||
|
type="linear"
|
||||||
|
stroke="var(--color-value)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="cursor-pointer text-xs text-muted-foreground">{t.table}</summary>
|
||||||
|
<table className="mt-2 w-full text-right tabular-nums">
|
||||||
|
<thead className="text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 text-left font-normal">{t.date}</th>
|
||||||
|
<th className="py-1 font-normal">{t.value}</th>
|
||||||
|
<th className="py-1 font-normal">{t.cost}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{report.points.map((p) => (
|
||||||
|
<tr key={p.date}>
|
||||||
|
<td className="py-1 text-left">{d(p.date)}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{p.valueMinor !== null ? formatMoney(p.valueMinor) : t.noPrice}
|
||||||
|
</td>
|
||||||
|
<td className="py-1">{formatMoney(p.costMinor)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.empty}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { listAccounts } from "@/features/accounts/service";
|
||||||
|
import { savePrice } from "@/features/in-kind/service";
|
||||||
|
import {
|
||||||
|
listTransactions,
|
||||||
|
setTransactionDeleted,
|
||||||
|
updateTransaction,
|
||||||
|
} from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { AssetModel } from "@/models/asset";
|
||||||
|
import { AssetMovementModel } from "@/models/asset-movement";
|
||||||
|
import { AssetPriceModel } from "@/models/asset-price";
|
||||||
|
import { AuditLogModel } from "@/models/audit-log";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||||
|
import { assetPriceFormSchema } from "@/shared/schemas/in-kind";
|
||||||
|
import { transactionFilterSchema, transactionFormSchema } from "@/shared/schemas/transaction";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
addMovement,
|
||||||
|
createAsset,
|
||||||
|
deleteAsset,
|
||||||
|
deleteMovement,
|
||||||
|
getAsset,
|
||||||
|
listAssets,
|
||||||
|
updateAsset,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const daysAgo = (n: number) => addDays(today, -n);
|
||||||
|
|
||||||
|
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");
|
||||||
|
// Seed örnekleri bu testin senaryolarını etkilemesin.
|
||||||
|
await Promise.all([
|
||||||
|
AssetPriceModel.deleteMany({}),
|
||||||
|
AssetModel.deleteMany({}),
|
||||||
|
AssetMovementModel.deleteMany({}),
|
||||||
|
TransactionModel.deleteMany({ assetId: { $ne: null } }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
const acc = (name: string) =>
|
||||||
|
AccountModel.findOne({ householdId: cakir, name })
|
||||||
|
.lean()
|
||||||
|
.then((a) => String(a!._id));
|
||||||
|
async function balance(accountId: string) {
|
||||||
|
return (await listAccounts(ayse, cakir))!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
|
||||||
|
}
|
||||||
|
const mv = (input: Record<string, unknown>) => movementFormSchema.parse({ date: today, ...input });
|
||||||
|
async function asset(name: string, assetKey = "gram_gold", isPrivate = false, by = ayse) {
|
||||||
|
const r = await createAsset(by, cakir, assetFormSchema.parse({ assetKey, name, isPrivate }));
|
||||||
|
return r.ok ? r.data.id : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("varlık lotları ve ortalama maliyet", () => {
|
||||||
|
let gold = "";
|
||||||
|
|
||||||
|
it("alım hesaptan transfer olarak çıkar; gelir/gider toplamları değişmez", async () => {
|
||||||
|
gold = await asset("Altın birikimi");
|
||||||
|
const ziraat = await acc("Ziraat Maaş");
|
||||||
|
const z0 = await balance(ziraat);
|
||||||
|
const sums0 = (await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums;
|
||||||
|
const r = await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({
|
||||||
|
kind: "buy",
|
||||||
|
date: daysAgo(60),
|
||||||
|
quantity: "10",
|
||||||
|
amount: "25.000",
|
||||||
|
accountId: ziraat,
|
||||||
|
storage: "Ev kasası",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(await balance(ziraat)).toBe(z0 - 25_000_00);
|
||||||
|
expect((await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums).toEqual(
|
||||||
|
sums0,
|
||||||
|
);
|
||||||
|
// İşlem listesinde varlık adıyla görünür.
|
||||||
|
const page = (await listTransactions(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
transactionFilterSchema.parse({ q: "Varlık alımı" }),
|
||||||
|
))!;
|
||||||
|
expect(page.items[0]).toMatchObject({ asset: { id: gold, name: "Altın birikimi" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("birim fiyatla alım ve fiyat tablosundan (satış fiyatı) alım; özet", async () => {
|
||||||
|
await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "buy", date: daysAgo(30), quantity: "2", unitPrice: "2.600" }),
|
||||||
|
);
|
||||||
|
// Fiyat yok → reddedilir.
|
||||||
|
expect(
|
||||||
|
(await addMovement(ayse, cakir, gold, mv({ kind: "buy", date: daysAgo(10), quantity: "2" })))
|
||||||
|
.ok,
|
||||||
|
).toBe(false);
|
||||||
|
await savePrice(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
assetPriceFormSchema.parse({
|
||||||
|
assetKey: "gram_gold",
|
||||||
|
date: daysAgo(10),
|
||||||
|
buy: "2.650",
|
||||||
|
sell: "2.700",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await addMovement(ayse, cakir, gold, mv({ kind: "buy", date: daysAgo(10), quantity: "2" })))
|
||||||
|
.ok,
|
||||||
|
).toBe(true);
|
||||||
|
await savePrice(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
assetPriceFormSchema.parse({
|
||||||
|
assetKey: "gram_gold",
|
||||||
|
date: today,
|
||||||
|
buy: "3.000",
|
||||||
|
sell: "3.100",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const v = (await getAsset(ayse, cakir, gold))!;
|
||||||
|
// 14 gr, 25.000 + 5.200 + 5.400 = 35.600; değer alış 3.000 ile 42.000.
|
||||||
|
expect(v.summary).toMatchObject({
|
||||||
|
quantity: 14_000,
|
||||||
|
costMinor: 35_600_00,
|
||||||
|
investedMinor: 35_600_00,
|
||||||
|
avgCostMinor: 2_542_86,
|
||||||
|
currentValueMinor: 42_000_00,
|
||||||
|
unrealizedMinor: 6_400_00,
|
||||||
|
unrealizedBp: 1798,
|
||||||
|
});
|
||||||
|
expect(v.storage).toBe("Ev kasası");
|
||||||
|
expect(v.movements[0]!.kind).toBe("buy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("satış: hesaba girer, ortalama maliyetle gerçekleşmiş K/Z; eldekinden fazlası reddedilir", async () => {
|
||||||
|
const cuzdan = await acc("Cüzdan");
|
||||||
|
const c0 = await balance(cuzdan);
|
||||||
|
expect(
|
||||||
|
(await addMovement(ayse, cakir, gold, mv({ kind: "sell", quantity: "15", amount: "1" }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
// Geçmiş tarihli satış o tarihteki miktarla sınırlı (60 gün önce yalnızca 10 gr vardı).
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "sell", date: daysAgo(45), quantity: "11", amount: "1" }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
const r = await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "sell", quantity: "4", amount: "12.000", accountId: cuzdan }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(await balance(cuzdan)).toBe(c0 + 12_000_00);
|
||||||
|
const v = (await getAsset(ayse, cakir, gold))!;
|
||||||
|
// 4 gr maliyeti: 35.600 × 4/14 = 10.171,43 → K/Z 1.828,57
|
||||||
|
expect(v.movements[0]).toMatchObject({
|
||||||
|
kind: "sell",
|
||||||
|
costOutMinor: 10_171_43,
|
||||||
|
realizedMinor: 1_828_57,
|
||||||
|
quantityAfter: 10_000,
|
||||||
|
});
|
||||||
|
expect(v.summary.realizedMinor).toBe(1_828_57);
|
||||||
|
expect(v.summary.proceedsMinor).toBe(12_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hediye çıkışı K/Z yazmaz, hesap hareketi oluşturmaz", async () => {
|
||||||
|
const before = (await getAsset(ayse, cakir, gold))!.summary;
|
||||||
|
const r = await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "gift_out", quantity: "1", note: "Yeğene" }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const m = await AssetMovementModel.findById(r.ok ? r.data.id : "").lean();
|
||||||
|
expect(m).toMatchObject({ amountMinor: 0, transactionId: null });
|
||||||
|
const after = (await getAsset(ayse, cakir, gold))!.summary;
|
||||||
|
expect(after.quantity).toBe(9_000);
|
||||||
|
expect(after.realizedMinor).toBe(before.realizedMinor);
|
||||||
|
expect(after.avgCostMinor).toBe(before.avgCostMinor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bağlı işlem tek başına düzenlenemez/silinemez; hareket silinince işlem de silinir", async () => {
|
||||||
|
const tx = (await AssetMovementModel.findOne({ assetId: gold, kind: "sell" }).lean())!;
|
||||||
|
const txId = String(tx.transactionId);
|
||||||
|
const locked = { ok: false, error: tr.transactions.assetLocked };
|
||||||
|
expect(await setTransactionDeleted(ayse, cakir, txId, true)).toMatchObject(locked);
|
||||||
|
const edit = transactionFormSchema.parse({
|
||||||
|
type: "transfer",
|
||||||
|
date: today,
|
||||||
|
amount: "1",
|
||||||
|
accountId: await acc("Cüzdan"),
|
||||||
|
toAccountId: await acc("Ziraat Maaş"),
|
||||||
|
});
|
||||||
|
expect(await updateTransaction(ayse, cakir, txId, edit)).toMatchObject(locked);
|
||||||
|
|
||||||
|
const cuzdan = await acc("Cüzdan");
|
||||||
|
const c0 = await balance(cuzdan);
|
||||||
|
expect((await deleteMovement(ayse, cakir, String(tx._id))).ok).toBe(true);
|
||||||
|
expect(await balance(cuzdan)).toBe(c0 - 12_000_00);
|
||||||
|
expect(
|
||||||
|
await AuditLogModel.exists({ action: "asset.movement.delete", targetId: String(tx._id) }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect((await getAsset(ayse, cakir, gold))!.summary.realizedMinor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("silinince sonraki çıkış eldekini aşacaksa reddedilir; hareketli varlık silinemez", async () => {
|
||||||
|
const first = (await AssetMovementModel.findOne({ assetId: gold, kind: "buy" })
|
||||||
|
.sort({ date: 1 })
|
||||||
|
.lean())!;
|
||||||
|
// Kalan: 10 + 2 + 2 alım, bugün 1 gr çıkış. Sonraki alımlar silinebilir; ilk alım silinirse
|
||||||
|
// çıkış eldekini aşar → reddedilir.
|
||||||
|
const buys = await AssetMovementModel.find({ assetId: gold, kind: "buy", deletedAt: null })
|
||||||
|
.sort({ date: -1 })
|
||||||
|
.lean();
|
||||||
|
expect(buys.at(-1)!._id.equals(first._id)).toBe(true);
|
||||||
|
for (const b of buys.slice(0, -1))
|
||||||
|
expect((await deleteMovement(ayse, cakir, String(b._id))).ok).toBe(true);
|
||||||
|
expect((await deleteMovement(ayse, cakir, String(first._id))).ok).toBe(false);
|
||||||
|
expect((await deleteAsset(ayse, cakir, gold)).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await updateAsset(ayse, cakir, gold, assetFormSchema.parse({ assetKey: "usd", name: "X" })))
|
||||||
|
.ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gelen hediye: maliyet o günkü alış fiyatı, yatırım sayılmaz", async () => {
|
||||||
|
const z = await asset("Düğün ziynetleri", "quarter_gold");
|
||||||
|
await savePrice(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
assetPriceFormSchema.parse({ assetKey: "quarter_gold", date: daysAgo(100), buy: "5.000" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
movementFormSchema.safeParse({
|
||||||
|
kind: "gift_in",
|
||||||
|
date: today,
|
||||||
|
quantity: "1",
|
||||||
|
accountId: await acc("Cüzdan"),
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await addMovement(ayse, cakir, z, mv({ kind: "gift_in", date: daysAgo(90), quantity: "4" })))
|
||||||
|
.ok,
|
||||||
|
).toBe(true);
|
||||||
|
const v = (await getAsset(ayse, cakir, z))!;
|
||||||
|
expect(v.summary).toMatchObject({ quantity: 4_000, costMinor: 20_000_00, investedMinor: 0 });
|
||||||
|
// Güncel değer: bugünden önceki en yakın fiyat.
|
||||||
|
expect(v.summary.currentValueMinor).toBe(20_000_00);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("görünürlük ve yetki", () => {
|
||||||
|
it("gizli varlık ve bağlı işlemi başkasına görünmez; izleyici yazamaz; başka hane göremez", async () => {
|
||||||
|
const secret = await asset("Emre dolar", "usd", true, emre);
|
||||||
|
await savePrice(
|
||||||
|
emre,
|
||||||
|
cakir,
|
||||||
|
assetPriceFormSchema.parse({ assetKey: "usd", date: today, buy: "40" }),
|
||||||
|
);
|
||||||
|
const r = await addMovement(
|
||||||
|
emre,
|
||||||
|
cakir,
|
||||||
|
secret,
|
||||||
|
mv({ kind: "buy", quantity: "100", amount: "4.000", accountId: await acc("Cüzdan") }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect((await listAssets(emre, cakir))!.items.some((a) => a.id === secret)).toBe(true);
|
||||||
|
expect((await listAssets(ayse, cakir))!.items.some((a) => a.id === secret)).toBe(false);
|
||||||
|
expect(await getAsset(ayse, cakir, secret)).toBeNull();
|
||||||
|
const ayseTx = (await listTransactions(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
transactionFilterSchema.parse({ q: "Emre dolar" }),
|
||||||
|
))!;
|
||||||
|
expect(ayseTx.total).toBe(0);
|
||||||
|
expect(
|
||||||
|
(await addMovement(ayse, cakir, secret, mv({ kind: "gift_out", quantity: "1" }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
const shared = (await listAssets(ayse, cakir))!.items[0]!.id;
|
||||||
|
expect(
|
||||||
|
(await addMovement(zeynep, cakir, shared, mv({ kind: "gift_out", quantity: "1" }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect((await listAssets(zeynep, cakir))!.canWrite).toBe(false);
|
||||||
|
expect(await listAssets(fatma, cakir)).toBeNull();
|
||||||
|
expect(await getAsset(fatma, cakir, shared)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toplamlar: fiyatı olmayan varlık toplam değere katılmaz", async () => {
|
||||||
|
const eur = await asset("Euro kumbara", "eur");
|
||||||
|
await addMovement(ayse, cakir, eur, mv({ kind: "gift_in", quantity: "50", amount: "2.400" }));
|
||||||
|
const data = (await listAssets(ayse, cakir))!;
|
||||||
|
expect(data.totals.missingPrice).toBe(1);
|
||||||
|
const withPrice = data.items.filter((i) => i.summary.currentValueMinor !== null);
|
||||||
|
expect(data.totals.valueMinor).toBe(
|
||||||
|
withPrice.reduce((a, i) => a + (i.summary.currentValueMinor ?? 0), 0),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
import "server-only";
|
||||||
|
import mongoose, { Types } from "mongoose";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { buyPriceLookup, priceOn, type PriceQuote } from "@/features/in-kind/prices";
|
||||||
|
import { loadContext, type Ctx } 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 { AssetModel } from "@/models/asset";
|
||||||
|
import { AssetMovementModel } from "@/models/asset-movement";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { formatAssetQuantity, valueOf, type AssetKey } from "@/shared/assets";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import {
|
||||||
|
holdingSummary,
|
||||||
|
periodReport,
|
||||||
|
runMovements,
|
||||||
|
sumReports,
|
||||||
|
type HoldingSummary,
|
||||||
|
type MovementKind,
|
||||||
|
type PeriodReport,
|
||||||
|
} from "@/shared/portfolio";
|
||||||
|
import { addDays, addMonths } from "@/shared/recurrence";
|
||||||
|
import { QUANTITY_SCALE } from "@/shared/quantity";
|
||||||
|
import type { AssetForm, MovementForm } from "@/shared/schemas/asset";
|
||||||
|
import { compareTr, foldTr } from "@/shared/search";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Birikim ve varlık takibi (Bölüm 6.6). Okuma: viewer; yazma: member. Varlıklar hane ortaktır;
|
||||||
|
* "yalnızca ben" işaretli varlık ve hareketleri (bağlı hesap işlemleri dahil) yalnızca sahibine
|
||||||
|
* görünür. Değerleme alış fiyatıyla (Faz 0). Alım/satış hesaba bağlıysa transfer işlemi oluşur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.assets;
|
||||||
|
|
||||||
|
type AssetDoc = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
assetKey: AssetKey;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
ownerUserId?: Types.ObjectId | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MovementDoc = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
assetId: Types.ObjectId;
|
||||||
|
kind: MovementKind;
|
||||||
|
date: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPriceMinor?: number | null;
|
||||||
|
amountMinor: number;
|
||||||
|
accountId?: Types.ObjectId | null;
|
||||||
|
transactionId?: Types.ObjectId | null;
|
||||||
|
storage: string;
|
||||||
|
note: string;
|
||||||
|
inKindDebtId?: Types.ObjectId | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetView = {
|
||||||
|
id: string;
|
||||||
|
assetKey: AssetKey;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
label: string;
|
||||||
|
price: PriceQuote | null;
|
||||||
|
summary: HoldingSummary;
|
||||||
|
/** Son alımın saklama yeri. */
|
||||||
|
storage: string;
|
||||||
|
movementCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetTotals = {
|
||||||
|
valueMinor: number;
|
||||||
|
costMinor: number;
|
||||||
|
unrealizedMinor: number;
|
||||||
|
realizedMinor: number;
|
||||||
|
/** Fiyatı olmadığı için toplam değere katılmayan (elde miktarı olan) varlık sayısı. */
|
||||||
|
missingPrice: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MovementRow = {
|
||||||
|
id: string;
|
||||||
|
kind: MovementKind;
|
||||||
|
date: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPriceMinor: number | null;
|
||||||
|
amountMinor: number;
|
||||||
|
account: string | null;
|
||||||
|
storage: string;
|
||||||
|
note: string;
|
||||||
|
costOutMinor: number;
|
||||||
|
realizedMinor: number;
|
||||||
|
quantityAfter: number;
|
||||||
|
fromInKind: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetDetail = AssetView & { movements: MovementRow[]; canWrite: boolean };
|
||||||
|
|
||||||
|
const sortMovements = (a: MovementDoc, b: MovementDoc) =>
|
||||||
|
a.date === b.date ? a.createdAt.getTime() - b.createdAt.getTime() : a.date < b.date ? -1 : 1;
|
||||||
|
|
||||||
|
function visibleAsset(ctx: Ctx, id: Types.ObjectId | string) {
|
||||||
|
const a = ctx.assetMap.get(String(id));
|
||||||
|
return a && !a.hidden && !a.deleted ? a : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMovements(householdId: Types.ObjectId, assetIds: Types.ObjectId[]) {
|
||||||
|
const docs = await AssetMovementModel.find({
|
||||||
|
householdId,
|
||||||
|
assetId: { $in: assetIds },
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<MovementDoc[]>();
|
||||||
|
const by = new Map<string, MovementDoc[]>();
|
||||||
|
for (const m of docs) {
|
||||||
|
const list = by.get(String(m.assetId)) ?? [];
|
||||||
|
list.push(m);
|
||||||
|
by.set(String(m.assetId), list);
|
||||||
|
}
|
||||||
|
for (const list of by.values()) list.sort(sortMovements);
|
||||||
|
return by;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toView(a: AssetDoc, movements: MovementDoc[], price: PriceQuote | null): AssetView {
|
||||||
|
const run = runMovements(movements);
|
||||||
|
const summary = holdingSummary(run, price?.buyMinor ?? null);
|
||||||
|
const storage = [...movements].reverse().find((m) => m.storage)?.storage ?? "";
|
||||||
|
return {
|
||||||
|
id: String(a._id),
|
||||||
|
assetKey: a.assetKey,
|
||||||
|
name: a.name,
|
||||||
|
note: a.note,
|
||||||
|
isPrivate: a.isPrivate,
|
||||||
|
label: formatAssetQuantity(a.assetKey, summary.quantity),
|
||||||
|
price,
|
||||||
|
summary,
|
||||||
|
storage,
|
||||||
|
movementCount: movements.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Görünür varlıklar ve toplamlar (net varlık ve dashboard da kullanır). */
|
||||||
|
export async function listAssets(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<{ items: AssetView[]; totals: AssetTotals; canWrite: boolean } | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
return { ...(await assetViews(ctx)), canWrite: hasHouseholdRole(ctx.m.role, "member") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assetViews(ctx: Ctx, date = todayIstanbul()) {
|
||||||
|
const docs = (
|
||||||
|
await AssetModel.find({ householdId: ctx.m.householdId, deletedAt: null }).lean<AssetDoc[]>()
|
||||||
|
).filter((a) => visibleAsset(ctx, a._id));
|
||||||
|
const movements = await loadMovements(
|
||||||
|
ctx.m.householdId,
|
||||||
|
docs.map((a) => a._id),
|
||||||
|
);
|
||||||
|
const prices = new Map<string, PriceQuote | null>();
|
||||||
|
for (const key of new Set(docs.map((a) => a.assetKey)))
|
||||||
|
prices.set(key, await priceOn(ctx.m.householdId, key, date));
|
||||||
|
const items = docs
|
||||||
|
.map((a) => toView(a, movements.get(String(a._id)) ?? [], prices.get(a.assetKey) ?? null))
|
||||||
|
.sort(
|
||||||
|
(x, y) =>
|
||||||
|
Number(y.summary.quantity > 0) - Number(x.summary.quantity > 0) ||
|
||||||
|
(y.summary.currentValueMinor ?? 0) - (x.summary.currentValueMinor ?? 0) ||
|
||||||
|
compareTr(x.name, y.name),
|
||||||
|
);
|
||||||
|
const totals: AssetTotals = {
|
||||||
|
valueMinor: 0,
|
||||||
|
costMinor: 0,
|
||||||
|
unrealizedMinor: 0,
|
||||||
|
realizedMinor: 0,
|
||||||
|
missingPrice: 0,
|
||||||
|
};
|
||||||
|
for (const v of items) {
|
||||||
|
totals.realizedMinor += v.summary.realizedMinor;
|
||||||
|
if (v.summary.quantity === 0) continue;
|
||||||
|
if (v.summary.currentValueMinor === null) {
|
||||||
|
totals.missingPrice++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
totals.valueMinor += v.summary.currentValueMinor;
|
||||||
|
totals.costMinor += v.summary.costMinor;
|
||||||
|
totals.unrealizedMinor += v.summary.unrealizedMinor ?? 0;
|
||||||
|
}
|
||||||
|
return { items, totals };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAsset(ctx: Ctx, assetId: string) {
|
||||||
|
const id = toObjectId(assetId);
|
||||||
|
if (!id || !visibleAsset(ctx, id)) return null;
|
||||||
|
return AssetModel.findOne({
|
||||||
|
_id: id,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<AssetDoc>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAsset(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
assetId: string,
|
||||||
|
): Promise<AssetDetail | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return null;
|
||||||
|
const movements = (await loadMovements(ctx.m.householdId, [a._id])).get(String(a._id)) ?? [];
|
||||||
|
const price = await priceOn(ctx.m.householdId, a.assetKey, todayIstanbul());
|
||||||
|
const run = runMovements(movements);
|
||||||
|
const rows = movements.map((m, i): MovementRow => {
|
||||||
|
const acc = m.accountId ? ctx.accountMap.get(String(m.accountId)) : undefined;
|
||||||
|
const step = run.steps[i]!;
|
||||||
|
return {
|
||||||
|
id: String(m._id),
|
||||||
|
kind: m.kind,
|
||||||
|
date: m.date,
|
||||||
|
quantity: m.quantity,
|
||||||
|
unitPriceMinor: m.unitPriceMinor ?? null,
|
||||||
|
amountMinor: m.amountMinor,
|
||||||
|
account: acc ? (acc.hidden ? tr.transactions.list.hiddenAccount : acc.name) : null,
|
||||||
|
storage: m.storage,
|
||||||
|
note: m.note,
|
||||||
|
costOutMinor: step.costOutMinor,
|
||||||
|
realizedMinor: step.realizedMinor,
|
||||||
|
quantityAfter: step.quantityAfter,
|
||||||
|
fromInKind: Boolean(m.inKindDebtId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...toView(a, movements, price),
|
||||||
|
movements: rows.reverse(),
|
||||||
|
canWrite: hasHouseholdRole(ctx.m.role, "member"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Varlık kaydı ---
|
||||||
|
|
||||||
|
export async function createAsset(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: AssetForm,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const doc = await AssetModel.create({
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
assetKey: input.assetKey,
|
||||||
|
name: input.name,
|
||||||
|
note: input.note,
|
||||||
|
isPrivate: input.isPrivate,
|
||||||
|
ownerUserId: input.isPrivate ? actor : null,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
return ok({ id: String(doc._id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAsset(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
assetId: string,
|
||||||
|
input: AssetForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return fail(tr.common.notFound);
|
||||||
|
if (
|
||||||
|
input.assetKey !== a.assetKey &&
|
||||||
|
(await AssetMovementModel.exists({ assetId: a._id, deletedAt: null }))
|
||||||
|
)
|
||||||
|
return fail(t.errors.assetKeyLocked, { assetKey: t.errors.assetKeyLocked });
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const owner = a.ownerUserId ?? (input.isPrivate ? actor : null);
|
||||||
|
await AssetModel.updateOne(
|
||||||
|
{ _id: a._id, householdId: ctx.m.householdId },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
assetKey: input.assetKey,
|
||||||
|
name: input.name,
|
||||||
|
note: input.note,
|
||||||
|
isPrivate: input.isPrivate,
|
||||||
|
ownerUserId: input.isPrivate ? owner : null,
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAsset(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
assetId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return fail(tr.common.notFound);
|
||||||
|
if (await AssetMovementModel.exists({ assetId: a._id, deletedAt: null }))
|
||||||
|
return fail(t.errors.hasMovements);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await AssetModel.updateOne({ _id: a._id }, { $set: { deletedAt: new Date(), updatedBy: actor } });
|
||||||
|
await writeAudit({
|
||||||
|
action: "asset.delete",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
targetType: "asset",
|
||||||
|
targetId: String(a._id),
|
||||||
|
diff: { name: a.name, assetKey: a.assetKey },
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hareketler ---
|
||||||
|
|
||||||
|
function checkAccount(ctx: Ctx, id: string | null) {
|
||||||
|
if (!id) return null;
|
||||||
|
const a = ctx.accountMap.get(id);
|
||||||
|
return a && !a.hidden && !a.archived ? a : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tutar ve birim fiyatı tamamlar (bkz. movementFormSchema). */
|
||||||
|
async function resolveAmount(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
assetKey: AssetKey,
|
||||||
|
input: MovementForm,
|
||||||
|
): Promise<{ unitPriceMinor: number | null; amountMinor: number } | null> {
|
||||||
|
if (input.kind === "gift_out") return { unitPriceMinor: null, amountMinor: 0 };
|
||||||
|
if (input.amount !== null)
|
||||||
|
return {
|
||||||
|
amountMinor: input.amount,
|
||||||
|
unitPriceMinor:
|
||||||
|
input.unitPrice ?? Math.round((input.amount * QUANTITY_SCALE) / input.quantity),
|
||||||
|
};
|
||||||
|
let unit = input.unitPrice;
|
||||||
|
if (unit === null) {
|
||||||
|
const p = await priceOn(householdId, assetKey, input.date);
|
||||||
|
// Alımda kurumun satış fiyatı ödenir; satış ve hediye değeri alış fiyatıyla.
|
||||||
|
unit = p ? (input.kind === "buy" ? p.sellMinor : p.buyMinor) : null;
|
||||||
|
}
|
||||||
|
if (unit === null) return null;
|
||||||
|
return { unitPriceMinor: unit, amountMinor: valueOf(input.quantity, unit) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMovement(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
assetId: string,
|
||||||
|
input: MovementForm,
|
||||||
|
opts: { inKindDebtId?: Types.ObjectId; session?: mongoose.ClientSession } = {},
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return fail(tr.common.notFound);
|
||||||
|
if (input.date > todayIstanbul()) return fail(t.errors.futureDate, { date: t.errors.futureDate });
|
||||||
|
const date = istanbulDayStart(input.date);
|
||||||
|
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
|
||||||
|
const account = checkAccount(ctx, input.accountId);
|
||||||
|
if (input.accountId && !account)
|
||||||
|
return fail(tr.transactions.validation.accountInvalid, {
|
||||||
|
accountId: tr.transactions.validation.accountInvalid,
|
||||||
|
});
|
||||||
|
const amount = await resolveAmount(ctx.m.householdId, a.assetKey, input);
|
||||||
|
if (!amount) return fail(t.errors.noPrice, { amount: t.errors.noPrice });
|
||||||
|
|
||||||
|
// Eldeki miktar: yeni hareket aynı günün sonuna eklenmiş gibi doğrulanır.
|
||||||
|
const existing = (await loadMovements(ctx.m.householdId, [a._id])).get(String(a._id)) ?? [];
|
||||||
|
const now = new Date();
|
||||||
|
const candidate = [
|
||||||
|
...existing,
|
||||||
|
{ kind: input.kind, date: input.date, quantity: input.quantity, createdAt: now } as MovementDoc,
|
||||||
|
]
|
||||||
|
.map((m) => ({ ...m, amountMinor: m.amountMinor ?? 0 }))
|
||||||
|
.sort(sortMovements);
|
||||||
|
if (runMovements(candidate).negativeAt !== null)
|
||||||
|
return fail(t.errors.insufficient, { quantity: t.errors.insufficient });
|
||||||
|
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const movementId = new Types.ObjectId();
|
||||||
|
const label = formatAssetQuantity(a.assetKey, input.quantity);
|
||||||
|
const write = async (session: mongoose.ClientSession) => {
|
||||||
|
let transactionId: Types.ObjectId | null = null;
|
||||||
|
if (account && amount.amountMinor > 0 && (input.kind === "buy" || input.kind === "sell")) {
|
||||||
|
const out = input.kind === "buy";
|
||||||
|
const description = t.txDescription[input.kind](label, a.name);
|
||||||
|
const [tx] = await TransactionModel.create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
type: "transfer",
|
||||||
|
status: "posted",
|
||||||
|
date,
|
||||||
|
// Alım hesaptan çıkar (varlığa); satış hesaba girer (varlıktan).
|
||||||
|
accountId: out ? account.id : null,
|
||||||
|
toAccountId: out ? null : account.id,
|
||||||
|
amountMinor: amount.amountMinor,
|
||||||
|
description,
|
||||||
|
searchText: foldTr(description),
|
||||||
|
assetId: a._id,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
transactionId = tx!._id;
|
||||||
|
}
|
||||||
|
await AssetMovementModel.create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
_id: movementId,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
assetId: a._id,
|
||||||
|
kind: input.kind,
|
||||||
|
date: input.date,
|
||||||
|
quantity: input.quantity,
|
||||||
|
unitPriceMinor: amount.unitPriceMinor,
|
||||||
|
amountMinor: amount.amountMinor,
|
||||||
|
accountId: account && transactionId ? account.id : null,
|
||||||
|
transactionId,
|
||||||
|
storage: input.storage,
|
||||||
|
note: input.note,
|
||||||
|
inKindDebtId: opts.inKindDebtId ?? null,
|
||||||
|
createdBy: actor,
|
||||||
|
createdAt: now,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (opts.session) await write(opts.session);
|
||||||
|
else await mongoose.connection.transaction(write);
|
||||||
|
return ok({ id: String(movementId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMovement(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
movementId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const id = toObjectId(movementId);
|
||||||
|
if (!id) return fail(tr.common.notFound);
|
||||||
|
const m = await AssetMovementModel.findOne({
|
||||||
|
_id: id,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<MovementDoc>();
|
||||||
|
if (!m || !visibleAsset(ctx, m.assetId)) return fail(tr.common.notFound);
|
||||||
|
const rest = (
|
||||||
|
(await loadMovements(ctx.m.householdId, [m.assetId])).get(String(m.assetId)) ?? []
|
||||||
|
).filter((x) => !x._id.equals(m._id));
|
||||||
|
if (runMovements(rest).negativeAt !== null) return fail(t.errors.deleteBreaks);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
const now = new Date();
|
||||||
|
await AssetMovementModel.updateOne({ _id: m._id }, { $set: { deletedAt: now } }, { session });
|
||||||
|
if (m.transactionId)
|
||||||
|
await TransactionModel.updateOne(
|
||||||
|
{ _id: m.transactionId, householdId: ctx.m.householdId },
|
||||||
|
{ $set: { deletedAt: now, updatedBy: actor } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
await writeAudit(
|
||||||
|
{
|
||||||
|
action: "asset.movement.delete",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
targetType: "assetMovement",
|
||||||
|
targetId: String(m._id),
|
||||||
|
diff: { kind: m.kind, quantity: m.quantity, amountMinor: m.amountMinor, date: m.date },
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dönemsel rapor ---
|
||||||
|
|
||||||
|
export const REPORT_RANGES = ["3", "6", "12", "tum"] as const;
|
||||||
|
export type ReportRange = (typeof REPORT_RANGES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seçilen aralıkta (son N ay veya ilk hareketten bugüne) yatırılan TL, dönem sonu değer,
|
||||||
|
* kâr/zarar ve aylık değer noktaları. `assetId` verilirse yalnızca o varlık, yoksa tüm görünür
|
||||||
|
* varlıkların toplamı.
|
||||||
|
*/
|
||||||
|
export async function getPeriodReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
range: ReportRange,
|
||||||
|
assetId?: string,
|
||||||
|
): Promise<(PeriodReport & { excluded: number }) | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
let docs: AssetDoc[];
|
||||||
|
if (assetId) {
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return null;
|
||||||
|
docs = [a];
|
||||||
|
} else {
|
||||||
|
docs = (
|
||||||
|
await AssetModel.find({ householdId: ctx.m.householdId, deletedAt: null }).lean<AssetDoc[]>()
|
||||||
|
).filter((a) => visibleAsset(ctx, a._id));
|
||||||
|
}
|
||||||
|
const movements = await loadMovements(
|
||||||
|
ctx.m.householdId,
|
||||||
|
docs.map((a) => a._id),
|
||||||
|
);
|
||||||
|
const to = todayIstanbul();
|
||||||
|
let from: string;
|
||||||
|
if (range === "tum") {
|
||||||
|
const first = [...movements.values()]
|
||||||
|
.flat()
|
||||||
|
.map((m) => m.date)
|
||||||
|
.sort()[0];
|
||||||
|
from = first && first < to ? first : addDays(to, -1);
|
||||||
|
} else from = addMonths(to, -Number(range));
|
||||||
|
const price = await buyPriceLookup(
|
||||||
|
ctx.m.householdId,
|
||||||
|
[...new Set(docs.map((a) => a.assetKey))],
|
||||||
|
to,
|
||||||
|
);
|
||||||
|
const reports = docs.map((a) =>
|
||||||
|
periodReport(movements.get(String(a._id)) ?? [], (date) => price(a.assetKey, date), from, to),
|
||||||
|
);
|
||||||
|
if (assetId) return { ...reports[0]!, excluded: 0 };
|
||||||
|
// Toplamda fiyatı eksik varlıklar (kâr hesaplanamayan) dışarıda bırakılır ve sayılır.
|
||||||
|
const priced = reports.filter((r) => r.profitMinor !== null);
|
||||||
|
return { ...sumReports(priced, from, to), excluded: reports.length - priced.length };
|
||||||
|
}
|
||||||
@@ -214,9 +214,7 @@ describe("cari defteri", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("aynı ad engellenir; arşivli cariye hareket eklenmez", async () => {
|
it("aynı ad engellenir; arşivli cariye hareket eklenmez", async () => {
|
||||||
expect((await createContact(ayse, cakir, contactForm({ name: "kemal TEST" }))).ok).toBe(
|
expect((await createContact(ayse, cakir, contactForm({ name: "kemal TEST" }))).ok).toBe(false);
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect((await setContactArchived(ayse, cakir, ahmet, true)).ok).toBe(true);
|
expect((await setContactArchived(ayse, cakir, ahmet, true)).ok).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
(await createContactEntry(ayse, cakir, ahmet, entry({ accountId: await acc("Cüzdan") }))).ok,
|
(await createContactEntry(ayse, cakir, ahmet, entry({ accountId: await acc("Cüzdan") }))).ok,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { getAssetOptionsAction, type AssetOption } from "@/features/assets/actions";
|
||||||
import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
|
import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
|
||||||
import { AccountSelect } from "@/features/transactions/components/transaction-form";
|
import { AccountSelect } from "@/features/transactions/components/transaction-form";
|
||||||
import type { TransactionFormOptions } from "@/features/transactions/service";
|
import type { TransactionFormOptions } from "@/features/transactions/service";
|
||||||
@@ -65,7 +67,7 @@ const qtyInput = (q: number) =>
|
|||||||
(q / QUANTITY_SCALE).toLocaleString("tr-TR", { maximumFractionDigits: 3, useGrouping: false });
|
(q / QUANTITY_SCALE).toLocaleString("tr-TR", { maximumFractionDigits: 3, useGrouping: false });
|
||||||
|
|
||||||
/** Hesap seçenekleri (diyaloglar açıldığında). */
|
/** Hesap seçenekleri (diyaloglar açıldığında). */
|
||||||
function useAccounts() {
|
export function useAccounts() {
|
||||||
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
|
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -79,6 +81,21 @@ function useAccounts() {
|
|||||||
return options?.accounts ?? null;
|
return options?.accounts ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kaydın varlık türündeki birikim varlıkları (ödeme diyaloğu açıldığında). */
|
||||||
|
function useAssetOptions(assetKey: AssetKey) {
|
||||||
|
const [options, setOptions] = useState<AssetOption[] | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getAssetOptionsAction().then((r) => {
|
||||||
|
if (!cancelled && r.ok) setOptions(r.data.filter((a) => a.assetKey === assetKey));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [assetKey]);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -602,16 +619,23 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
|
|||||||
});
|
});
|
||||||
const [errors, setErrors] = useState<Errors>({});
|
const [errors, setErrors] = useState<Errors>({});
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const assets = useAssetOptions(item.assetKey);
|
||||||
|
const [link, setLink] = useState(false);
|
||||||
|
const [assetId, setAssetId] = useState("");
|
||||||
|
const linked = method === "asset" && link;
|
||||||
|
const linkAssetId = assetId || assets?.[0]?.id || "";
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
const amount = linked ? "" : v.amount;
|
||||||
const r = await recordPaymentAction(item.id, {
|
const r = await recordPaymentAction(item.id, {
|
||||||
method,
|
method,
|
||||||
date: v.date,
|
date: v.date,
|
||||||
quantity: method === "cash" ? "" : v.quantity,
|
quantity: method === "cash" ? "" : v.quantity,
|
||||||
amount: v.amount,
|
amount,
|
||||||
accountId: v.amount ? v.accountId || accounts?.[0]?.id || "" : "",
|
accountId: amount ? v.accountId || accounts?.[0]?.id || "" : "",
|
||||||
|
assetId: linked ? linkAssetId : "",
|
||||||
note: v.note,
|
note: v.note,
|
||||||
});
|
});
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -659,17 +683,19 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
|
|||||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||||
</Field>
|
</Field>
|
||||||
) : null}
|
) : null}
|
||||||
<Field data-invalid={Boolean(errors.amount)}>
|
{linked || (method === "asset" && !debt) ? null : (
|
||||||
<FieldLabel htmlFor="pay-amount">
|
<Field data-invalid={Boolean(errors.amount)}>
|
||||||
{method === "cash" ? f.amount : f.assetAmount}
|
<FieldLabel htmlFor="pay-amount">
|
||||||
</FieldLabel>
|
{method === "cash" ? f.amount : f.assetAmount}
|
||||||
<MoneyInput
|
</FieldLabel>
|
||||||
id="pay-amount"
|
<MoneyInput
|
||||||
value={v.amount}
|
id="pay-amount"
|
||||||
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
value={v.amount}
|
||||||
/>
|
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
||||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
/>
|
||||||
</Field>
|
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel>
|
<FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
@@ -684,7 +710,49 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
|
|||||||
{method === "cash" ? (
|
{method === "cash" ? (
|
||||||
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
|
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
|
||||||
) : null}
|
) : null}
|
||||||
{method === "cash" || v.amount ? (
|
{method === "asset" ? (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={link}
|
||||||
|
onCheckedChange={(x) => setLink(x === true)}
|
||||||
|
disabled={!assets?.length}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{debt ? t.link.debt : t.link.receivable}
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{assets && !assets.length
|
||||||
|
? t.link.none
|
||||||
|
: debt
|
||||||
|
? t.link.debtHint
|
||||||
|
: t.link.receivableHint}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{link && assets?.length ? (
|
||||||
|
<Field data-invalid={Boolean(errors.assetId)}>
|
||||||
|
<FieldLabel htmlFor="pay-asset" className="sr-only">
|
||||||
|
{t.link.asset}
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={linkAssetId} onValueChange={setAssetId}>
|
||||||
|
<SelectTrigger id="pay-asset" className="w-full">
|
||||||
|
<SelectValue>{assets.find((a) => a.id === linkAssetId)?.name}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{assets.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.name} · {formatAssetQuantity(item.assetKey, a.quantity)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.assetId ? <FieldError>{errors.assetId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{(method === "cash" || v.amount) && !linked ? (
|
||||||
<Field data-invalid={Boolean(errors.accountId)}>
|
<Field data-invalid={Boolean(errors.accountId)}>
|
||||||
<FieldLabel htmlFor="pay-account">{f.account}</FieldLabel>
|
<FieldLabel htmlFor="pay-account">{f.account}</FieldLabel>
|
||||||
<AccountSelect
|
<AccountSelect
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import "server-only";
|
||||||
|
import type { Types } from "mongoose";
|
||||||
|
import { AssetPriceModel } from "@/models/asset-price";
|
||||||
|
import type { AssetKey } from "@/shared/assets";
|
||||||
|
|
||||||
|
/** Fiyat tablosu okuma yardımcıları (aynî kayıtlar ve varlık takibi ortak kullanır). */
|
||||||
|
|
||||||
|
export type PriceQuote = { buyMinor: number; sellMinor: number; date: string };
|
||||||
|
|
||||||
|
/** Varlığın verilen tarihteki (veya öncesindeki en yakın) fiyatı. */
|
||||||
|
export async function priceOn(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
assetKey: AssetKey,
|
||||||
|
date: string,
|
||||||
|
): Promise<PriceQuote | null> {
|
||||||
|
const p = await AssetPriceModel.findOne({ householdId, assetKey, date: { $lte: date } })
|
||||||
|
.sort({ date: -1 })
|
||||||
|
.lean();
|
||||||
|
return p ? { buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Varlıkların `to` tarihine kadarki tüm fiyatları; dönen fonksiyon bir tarihteki (veya
|
||||||
|
* öncesindeki en yakın) alış fiyatını verir (yoksa null).
|
||||||
|
*/
|
||||||
|
export async function buyPriceLookup(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
assetKeys: AssetKey[],
|
||||||
|
to: string,
|
||||||
|
): Promise<(assetKey: AssetKey, date: string) => number | null> {
|
||||||
|
const rows = await AssetPriceModel.find({
|
||||||
|
householdId,
|
||||||
|
assetKey: { $in: assetKeys },
|
||||||
|
date: { $lte: to },
|
||||||
|
})
|
||||||
|
.sort({ date: 1 })
|
||||||
|
.lean();
|
||||||
|
const by = new Map<string, { date: string; buyMinor: number }[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const list = by.get(r.assetKey) ?? [];
|
||||||
|
list.push({ date: r.date, buyMinor: r.buyMinor });
|
||||||
|
by.set(r.assetKey, list);
|
||||||
|
}
|
||||||
|
return (assetKey, date) => {
|
||||||
|
const list = by.get(assetKey);
|
||||||
|
if (!list) return null;
|
||||||
|
// İkili arama: date'ten küçük-eşit son kayıt.
|
||||||
|
let lo = 0;
|
||||||
|
let hi = list.length - 1;
|
||||||
|
let found = -1;
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (list[mid]!.date <= date) {
|
||||||
|
found = mid;
|
||||||
|
lo = mid + 1;
|
||||||
|
} else hi = mid - 1;
|
||||||
|
}
|
||||||
|
return found === -1 ? null : list[found]!.buyMinor;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import { toObjectId } from "@/lib/object-id";
|
|||||||
import { AssetPriceModel } from "@/models/asset-price";
|
import { AssetPriceModel } from "@/models/asset-price";
|
||||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||||
import { TransactionModel } from "@/models/transaction";
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { addMovement } from "@/features/assets/service";
|
||||||
|
import { AssetMovementModel } from "@/models/asset-movement";
|
||||||
|
import { priceOn, type PriceQuote } from "./prices";
|
||||||
import {
|
import {
|
||||||
ASSET_KEYS,
|
ASSET_KEYS,
|
||||||
formatAssetQuantity,
|
formatAssetQuantity,
|
||||||
@@ -38,19 +41,7 @@ const t = tr.inKind;
|
|||||||
|
|
||||||
// --- Fiyat tablosu ---
|
// --- Fiyat tablosu ---
|
||||||
|
|
||||||
export type PriceQuote = { buyMinor: number; sellMinor: number; date: string };
|
export { priceOn, type PriceQuote } from "./prices";
|
||||||
|
|
||||||
/** Varlığın verilen tarihteki (veya öncesindeki en yakın) fiyatı. */
|
|
||||||
export async function priceOn(
|
|
||||||
householdId: Types.ObjectId,
|
|
||||||
assetKey: AssetKey,
|
|
||||||
date: string,
|
|
||||||
): Promise<PriceQuote | null> {
|
|
||||||
const p = await AssetPriceModel.findOne({ householdId, assetKey, date: { $lte: date } })
|
|
||||||
.sort({ date: -1 })
|
|
||||||
.lean();
|
|
||||||
return p ? { buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] };
|
export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] };
|
||||||
|
|
||||||
@@ -381,6 +372,11 @@ export async function deleteInKind(
|
|||||||
{ _id: d._id },
|
{ _id: d._id },
|
||||||
{ $set: { deletedAt: new Date(), updatedBy: actor } },
|
{ $set: { deletedAt: new Date(), updatedBy: actor } },
|
||||||
);
|
);
|
||||||
|
// Bağlı varlık hareketleri serbest kalır (Birikim sayfasından yönetilebilir).
|
||||||
|
await AssetMovementModel.updateMany(
|
||||||
|
{ householdId: ctx.m.householdId, inKindDebtId: d._id },
|
||||||
|
{ $set: { inKindDebtId: null } },
|
||||||
|
);
|
||||||
await writeAudit({
|
await writeAudit({
|
||||||
action: "inkind.delete",
|
action: "inkind.delete",
|
||||||
actorId: actor,
|
actorId: actor,
|
||||||
@@ -491,7 +487,15 @@ export async function recordPayment(
|
|||||||
contactName,
|
contactName,
|
||||||
);
|
);
|
||||||
|
|
||||||
await mongoose.connection.transaction(async (session) => {
|
if (input.assetId) {
|
||||||
|
const asset = ctx.assetMap.get(input.assetId);
|
||||||
|
if (!asset || asset.hidden || asset.deleted)
|
||||||
|
return fail(tr.common.notFound, { assetId: tr.common.notFound });
|
||||||
|
if (asset.assetKey !== d.assetKey)
|
||||||
|
return fail(t.link.assetMismatch, { assetId: t.link.assetMismatch });
|
||||||
|
}
|
||||||
|
|
||||||
|
const writePayment = async (session: mongoose.ClientSession) => {
|
||||||
let transactionId: Types.ObjectId | null = null;
|
let transactionId: Types.ObjectId | null = null;
|
||||||
if (amountMinor > 0 && account) {
|
if (amountMinor > 0 && account) {
|
||||||
const out = d.direction === "debt";
|
const out = d.direction === "debt";
|
||||||
@@ -549,10 +553,50 @@ export async function recordPayment(
|
|||||||
},
|
},
|
||||||
session,
|
session,
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
// Varlık bağlantısı: borç ödemesi varlıktan çıkış, alacak tahsilatı varlığa giriş.
|
||||||
|
if (input.assetId) {
|
||||||
|
const r = await addMovement(
|
||||||
|
actorId,
|
||||||
|
householdId,
|
||||||
|
input.assetId,
|
||||||
|
{
|
||||||
|
kind: d.direction === "debt" ? "gift_out" : "gift_in",
|
||||||
|
date: input.date,
|
||||||
|
quantity,
|
||||||
|
unitPrice: d.direction === "debt" ? null : (d.unitPriceMinor ?? null),
|
||||||
|
amount: null,
|
||||||
|
accountId: null,
|
||||||
|
storage: "",
|
||||||
|
note: t.link.movementNote(contactName),
|
||||||
|
},
|
||||||
|
{ inKindDebtId: d._id, session },
|
||||||
|
);
|
||||||
|
if (!r.ok) throw new LinkError(r.error, r.fieldErrors?.quantity);
|
||||||
|
}
|
||||||
|
await writePayment(session);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LinkError)
|
||||||
|
return fail(error.message, error.quantity ? { quantity: error.quantity } : undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return ok({ closed });
|
return ok({ closed });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Varlık hareketi eklenemedi: transaction geri alınır, hata kullanıcıya döner. */
|
||||||
|
class LinkError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly quantity?: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Cari listesi için açık aynî kayıt özetleri (cari kimliği → etiketler). */
|
/** Cari listesi için açık aynî kayıt özetleri (cari kimliği → etiketler). */
|
||||||
export async function openInKindByContact(
|
export async function openInKindByContact(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import type { NetWorth } from "../service";
|
||||||
|
|
||||||
|
const t = tr.assets.netWorth;
|
||||||
|
|
||||||
|
/** Net varlık dökümü: artılar ve eksiler, altta toplam. */
|
||||||
|
export function NetWorthCard({ data }: { data: NetWorth }) {
|
||||||
|
const rows: { label: string; minor: number; sign: 1 | -1 }[] = [
|
||||||
|
{ label: t.accounts, minor: data.accountsMinor, sign: 1 },
|
||||||
|
{ label: t.assets, minor: data.assetsMinor, sign: 1 },
|
||||||
|
{ label: t.contactReceivable, minor: data.contactReceivableMinor, sign: 1 },
|
||||||
|
{ label: t.inKindReceivable, minor: data.inKindReceivableMinor, sign: 1 },
|
||||||
|
{ label: t.cardDebt, minor: data.cardDebtMinor, sign: -1 },
|
||||||
|
{ label: t.contactPayable, minor: data.contactPayableMinor, sign: -1 },
|
||||||
|
{ label: t.inKindPayable, minor: data.inKindPayableMinor, sign: -1 },
|
||||||
|
];
|
||||||
|
const plus = rows.filter((r) => r.sign === 1);
|
||||||
|
const minus = rows.filter((r) => r.sign === -1);
|
||||||
|
const list = (items: typeof rows) => (
|
||||||
|
<dl className="flex flex-col text-sm">
|
||||||
|
{items.map((r) => (
|
||||||
|
<div key={r.label} className="flex items-center justify-between gap-2 py-1">
|
||||||
|
<dt className="text-muted-foreground">{r.label}</dt>
|
||||||
|
<dd className="tabular-nums">
|
||||||
|
{r.sign < 0 && r.minor ? "−" : ""}
|
||||||
|
{formatMoney(r.minor)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-3xl font-semibold tracking-tight tabular-nums",
|
||||||
|
data.totalMinor < 0 && "text-expense",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMoney(data.totalMinor)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{t.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-x-8 gap-y-1 border-t pt-2 sm:grid-cols-2">
|
||||||
|
{list(plus)}
|
||||||
|
{list(minus)}
|
||||||
|
</div>
|
||||||
|
{data.missingPrice ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.missingPrice(data.missingPrice)}</p>
|
||||||
|
) : null}
|
||||||
|
{data.includesHidden ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.includesHidden}</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { addMovement, createAsset, getAsset, getPeriodReport } from "@/features/assets/service";
|
||||||
|
import { createContactEntry, createContact } from "@/features/contacts/service";
|
||||||
|
import {
|
||||||
|
createInKind,
|
||||||
|
deleteInKind,
|
||||||
|
listInKind,
|
||||||
|
recordPayment,
|
||||||
|
savePrice,
|
||||||
|
} from "@/features/in-kind/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { AssetModel } from "@/models/asset";
|
||||||
|
import { AssetMovementModel } from "@/models/asset-movement";
|
||||||
|
import { AssetPriceModel } from "@/models/asset-price";
|
||||||
|
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays, addMonths } from "@/shared/recurrence";
|
||||||
|
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||||
|
import { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact";
|
||||||
|
import {
|
||||||
|
assetPriceFormSchema,
|
||||||
|
inKindFormSchema,
|
||||||
|
paymentFormSchema,
|
||||||
|
} from "@/shared/schemas/in-kind";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { getNetWorth } from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
const today = todayIstanbul();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
emre = ids.user("emre");
|
||||||
|
// Seed varlık/fiyat/aynî örnekleri senaryoları etkilemesin.
|
||||||
|
await Promise.all([
|
||||||
|
AssetPriceModel.deleteMany({}),
|
||||||
|
AssetModel.deleteMany({}),
|
||||||
|
AssetMovementModel.deleteMany({}),
|
||||||
|
InKindDebtModel.deleteMany({}),
|
||||||
|
TransactionModel.deleteMany({
|
||||||
|
$or: [{ assetId: { $ne: null } }, { inKindDebtId: { $ne: null } }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
const acc = (name: string) =>
|
||||||
|
AccountModel.findOne({ householdId: cakir, name })
|
||||||
|
.lean()
|
||||||
|
.then((a) => String(a!._id));
|
||||||
|
const price = (assetKey: string, date: string, buy: string, sell = "") =>
|
||||||
|
savePrice(ayse, cakir, assetPriceFormSchema.parse({ assetKey, date, buy, sell }));
|
||||||
|
const mv = (input: Record<string, unknown>) => movementFormSchema.parse({ date: today, ...input });
|
||||||
|
async function asset(name: string, assetKey = "gram_gold", isPrivate = false, by = ayse) {
|
||||||
|
const r = await createAsset(by, cakir, assetFormSchema.parse({ assetKey, name, isPrivate }));
|
||||||
|
return r.ok ? r.data.id : "";
|
||||||
|
}
|
||||||
|
async function contact(name: string) {
|
||||||
|
const r = await createContact(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
contactFormSchema.parse({ name, openingDate: addDays(today, -200) }),
|
||||||
|
);
|
||||||
|
return r.ok ? r.data.id : "";
|
||||||
|
}
|
||||||
|
const netWorth = async (user = ayse) => (await getNetWorth(user, cakir))!;
|
||||||
|
|
||||||
|
describe("dönemsel rapor", () => {
|
||||||
|
let gold = "";
|
||||||
|
|
||||||
|
it("son 6 ay: yatırılan, dönem sonu değer ve kâr; aylık noktalar", async () => {
|
||||||
|
const m6 = addMonths(today, -6);
|
||||||
|
await price("gram_gold", addDays(m6, -10), "2.500", "2.550");
|
||||||
|
await price("gram_gold", addMonths(today, -3), "2.800", "2.850");
|
||||||
|
await price("gram_gold", today, "3.000", "3.050");
|
||||||
|
gold = await asset("Rapor altın");
|
||||||
|
// Dönem öncesi 2 gr (başlangıç değeri), dönem içinde 10 gr + 1 gr.
|
||||||
|
await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "buy", date: addDays(m6, -5), quantity: "2", amount: "5.000" }),
|
||||||
|
);
|
||||||
|
await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "buy", date: addDays(m6, 3), quantity: "10", amount: "25.000" }),
|
||||||
|
);
|
||||||
|
await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "buy", date: addMonths(today, -2), quantity: "1", amount: "2.850" }),
|
||||||
|
);
|
||||||
|
const r = (await getPeriodReport(ayse, cakir, "6", gold))!;
|
||||||
|
expect(r.from).toBe(m6);
|
||||||
|
expect(r.startValueMinor).toBe(5_000_00);
|
||||||
|
expect(r.investedMinor).toBe(27_850_00);
|
||||||
|
expect(r.endValueMinor).toBe(39_000_00);
|
||||||
|
expect(r.profitMinor).toBe(39_000_00 - 5_000_00 - 27_850_00);
|
||||||
|
expect(r.points[0]!.date).toBe(m6);
|
||||||
|
expect(r.points.at(-1)).toMatchObject({
|
||||||
|
date: today,
|
||||||
|
valueMinor: 39_000_00,
|
||||||
|
costMinor: 32_850_00,
|
||||||
|
});
|
||||||
|
expect(r.points.length).toBeGreaterThanOrEqual(7);
|
||||||
|
// "Tümü" ilk hareketten başlar; başlangıç değeri 0, kâr = değer − maliyet.
|
||||||
|
const all = (await getPeriodReport(ayse, cakir, "tum", gold))!;
|
||||||
|
expect(all.startValueMinor).toBe(0);
|
||||||
|
expect(all.profitMinor).toBe(39_000_00 - 32_850_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toplam rapor gizli varlığı başkasına katmaz; fiyatsız varlık dışarıda sayılır", async () => {
|
||||||
|
const secret = await asset("Gizli altın", "gram_gold", true, emre);
|
||||||
|
await addMovement(emre, cakir, secret, mv({ kind: "buy", quantity: "5", amount: "15.000" }));
|
||||||
|
const silver = await asset("Gümüş", "silver_gram");
|
||||||
|
await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
silver,
|
||||||
|
mv({ kind: "gift_in", quantity: "100", amount: "4.000" }),
|
||||||
|
);
|
||||||
|
const forAyse = (await getPeriodReport(ayse, cakir, "tum"))!;
|
||||||
|
expect(forAyse.investedMinor).toBe(32_850_00);
|
||||||
|
expect(forAyse.excluded).toBe(1);
|
||||||
|
const forEmre = (await getPeriodReport(emre, cakir, "tum"))!;
|
||||||
|
expect(forEmre.investedMinor).toBe(32_850_00 + 15_000_00);
|
||||||
|
expect(await getPeriodReport(ayse, cakir, "6", secret)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("net varlık", () => {
|
||||||
|
it("formül: hesaplar − kart borcu + varlıklar + alacaklar − borçlar", async () => {
|
||||||
|
const n = await netWorth();
|
||||||
|
expect(n.totalMinor).toBe(
|
||||||
|
n.accountsMinor -
|
||||||
|
n.cardDebtMinor +
|
||||||
|
n.assetsMinor +
|
||||||
|
n.contactReceivableMinor -
|
||||||
|
n.contactPayableMinor +
|
||||||
|
n.inKindReceivableMinor -
|
||||||
|
n.inKindPayableMinor,
|
||||||
|
);
|
||||||
|
// Gümüşün fiyatı yok → katılmaz ve sayılır.
|
||||||
|
expect(n.missingPrice).toBe(1);
|
||||||
|
// Emre kendi gizli altınını da görür: 5 × 3.000.
|
||||||
|
expect((await netWorth(emre)).assetsMinor - n.assetsMinor).toBe(15_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("satış fiyatından alım net varlığı yalnızca makas kadar düşürür; cariden borç almak değiştirmez", async () => {
|
||||||
|
const gold = await asset("Makas altın");
|
||||||
|
const n0 = (await netWorth()).totalMinor;
|
||||||
|
await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "buy", quantity: "1", accountId: await acc("Ziraat Maaş") }),
|
||||||
|
);
|
||||||
|
expect((await netWorth()).totalMinor).toBe(n0 - 50_00);
|
||||||
|
|
||||||
|
const ali = await contact("Net Ali");
|
||||||
|
const n1 = (await netWorth()).totalMinor;
|
||||||
|
await createContactEntry(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
ali,
|
||||||
|
contactEntrySchema.parse({
|
||||||
|
kind: "borrow",
|
||||||
|
accountId: await acc("Cüzdan"),
|
||||||
|
amount: "1.000",
|
||||||
|
date: today,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const n2 = await netWorth();
|
||||||
|
expect(n2.totalMinor).toBe(n1);
|
||||||
|
expect(n2.contactPayableMinor).toBeGreaterThanOrEqual(1_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aynî borç satış, aynî alacak alış fiyatıyla düşülür/eklenir", async () => {
|
||||||
|
const hasan = await contact("Net Hasan");
|
||||||
|
const n0 = (await netWorth()).totalMinor;
|
||||||
|
await createInKind(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
hasan,
|
||||||
|
inKindFormSchema.parse({
|
||||||
|
direction: "debt",
|
||||||
|
assetKey: "gram_gold",
|
||||||
|
quantity: "2",
|
||||||
|
date: today,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect((await netWorth()).totalMinor).toBe(n0 - 6_100_00);
|
||||||
|
await createInKind(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
hasan,
|
||||||
|
inKindFormSchema.parse({
|
||||||
|
direction: "receivable",
|
||||||
|
assetKey: "gram_gold",
|
||||||
|
quantity: "1",
|
||||||
|
date: today,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect((await netWorth()).totalMinor).toBe(n0 - 6_100_00 + 3_000_00);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("aynî kayıt ↔ varlık bağlantısı", () => {
|
||||||
|
it("borç aynı varlıkla ödenirken varlıktan düşer; yetersizse hiçbir şey yazılmaz", async () => {
|
||||||
|
const kasa = await asset("Kasa altın");
|
||||||
|
await addMovement(ayse, cakir, kasa, mv({ kind: "buy", quantity: "3", amount: "9.000" }));
|
||||||
|
const veli = await contact("Bağ Veli");
|
||||||
|
const debt = await createInKind(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
veli,
|
||||||
|
inKindFormSchema.parse({
|
||||||
|
direction: "debt",
|
||||||
|
assetKey: "gram_gold",
|
||||||
|
quantity: "5",
|
||||||
|
date: today,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const debtId = debt.ok ? debt.data.id : "";
|
||||||
|
|
||||||
|
// 4 gr ödemek istiyor ama kasada 3 gr var → reddedilir, ödeme yazılmaz.
|
||||||
|
const fail = await recordPayment(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
debtId,
|
||||||
|
paymentFormSchema.parse({ method: "asset", date: today, quantity: "4", assetId: kasa }),
|
||||||
|
);
|
||||||
|
expect(fail.ok).toBe(false);
|
||||||
|
expect((await InKindDebtModel.findById(debtId).lean())!.payments).toHaveLength(0);
|
||||||
|
|
||||||
|
const r = await recordPayment(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
debtId,
|
||||||
|
paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: kasa }),
|
||||||
|
);
|
||||||
|
expect(r).toMatchObject({ ok: true, data: { closed: false } });
|
||||||
|
const a = (await getAsset(ayse, cakir, kasa))!;
|
||||||
|
expect(a.summary.quantity).toBe(1_000);
|
||||||
|
expect(a.movements[0]).toMatchObject({ kind: "gift_out", quantity: 2_000, fromInKind: true });
|
||||||
|
expect((await listInKind(ayse, cakir, veli))![0]!.paidQuantity).toBe(2_000);
|
||||||
|
|
||||||
|
// Kayıt silinince hareket serbest kalır.
|
||||||
|
await deleteInKind(ayse, cakir, debtId);
|
||||||
|
expect((await getAsset(ayse, cakir, kasa))!.movements[0]!.fromInKind).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("alacak aynı varlıkla tahsil edilince varlığa borç tarihindeki fiyatla eklenir", async () => {
|
||||||
|
const ziynet = await asset("Ziynet kutusu", "quarter_gold");
|
||||||
|
const ayten = await contact("Bağ Ayten");
|
||||||
|
const rec = await createInKind(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
ayten,
|
||||||
|
inKindFormSchema.parse({
|
||||||
|
direction: "receivable",
|
||||||
|
assetKey: "quarter_gold",
|
||||||
|
quantity: "2",
|
||||||
|
date: addDays(today, -30),
|
||||||
|
unitPrice: "5.000",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const recId = rec.ok ? rec.data.id : "";
|
||||||
|
const gold = await asset("Yanlış tür");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await recordPayment(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
recId,
|
||||||
|
paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: gold }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
paymentFormSchema.safeParse({
|
||||||
|
method: "cash",
|
||||||
|
date: today,
|
||||||
|
amount: "1",
|
||||||
|
accountId: await acc("Cüzdan"),
|
||||||
|
assetId: ziynet,
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
const r = await recordPayment(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
recId,
|
||||||
|
paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: ziynet }),
|
||||||
|
);
|
||||||
|
expect(r).toMatchObject({ ok: true, data: { closed: true } });
|
||||||
|
const a = (await getAsset(ayse, cakir, ziynet))!;
|
||||||
|
expect(a.summary).toMatchObject({ quantity: 2_000, costMinor: 10_000_00, investedMinor: 0 });
|
||||||
|
expect(a.movements[0]).toMatchObject({ kind: "gift_in", fromInKind: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { listAccounts } from "@/features/accounts/service";
|
||||||
|
import { listAssets } from "@/features/assets/service";
|
||||||
|
import { listContacts } from "@/features/contacts/service";
|
||||||
|
import { listInKind } from "@/features/in-kind/service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Net varlık (Bölüm 6.6): hesap bakiyeleri (kart borçları eksi) + varlıkların güncel değeri
|
||||||
|
* (alış) + cari alacaklar − cari borçlar + aynî alacaklar (alış) − aynî borçlar (satış).
|
||||||
|
* Görünürlük kaynak listelerin kurallarını izler (gizli varlık/cari sayılmaz; gizli hesap
|
||||||
|
* bakiyeleri hane ayarına göre toplama girer).
|
||||||
|
*/
|
||||||
|
export type NetWorth = {
|
||||||
|
accountsMinor: number;
|
||||||
|
cardDebtMinor: number;
|
||||||
|
assetsMinor: number;
|
||||||
|
contactReceivableMinor: number;
|
||||||
|
contactPayableMinor: number;
|
||||||
|
inKindReceivableMinor: number;
|
||||||
|
inKindPayableMinor: number;
|
||||||
|
totalMinor: number;
|
||||||
|
/** Fiyatı olmadığı için katılmayan varlık + aynî kayıt sayısı. */
|
||||||
|
missingPrice: number;
|
||||||
|
/** Başkasının gizli hesabının bakiyesi toplama dahil (hane ayarı). */
|
||||||
|
includesHidden: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getNetWorth(userId: string, householdId: string): Promise<NetWorth | null> {
|
||||||
|
const [accounts, assets, contacts, inKind] = await Promise.all([
|
||||||
|
listAccounts(userId, householdId),
|
||||||
|
listAssets(userId, householdId),
|
||||||
|
listContacts(userId, householdId),
|
||||||
|
listInKind(userId, householdId),
|
||||||
|
]);
|
||||||
|
if (!accounts || !assets || !contacts || !inKind) return null;
|
||||||
|
let inKindReceivableMinor = 0;
|
||||||
|
let inKindPayableMinor = 0;
|
||||||
|
let missingPrice = assets.totals.missingPrice;
|
||||||
|
for (const v of inKind) {
|
||||||
|
if (v.closedAt || v.progress.remainingQuantity === 0) continue;
|
||||||
|
const value = v.progress.currentValueMinor;
|
||||||
|
if (value === null) {
|
||||||
|
missingPrice++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (v.direction === "debt") inKindPayableMinor += value;
|
||||||
|
else inKindReceivableMinor += value;
|
||||||
|
}
|
||||||
|
const r = {
|
||||||
|
accountsMinor: accounts.totals.assetsMinor,
|
||||||
|
cardDebtMinor: -accounts.totals.cardDebtMinor,
|
||||||
|
assetsMinor: assets.totals.valueMinor,
|
||||||
|
contactReceivableMinor: contacts.totals.owesMeMinor,
|
||||||
|
contactPayableMinor: contacts.totals.iOweMinor,
|
||||||
|
inKindReceivableMinor,
|
||||||
|
inKindPayableMinor,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
totalMinor:
|
||||||
|
r.accountsMinor -
|
||||||
|
r.cardDebtMinor +
|
||||||
|
r.assetsMinor +
|
||||||
|
r.contactReceivableMinor -
|
||||||
|
r.contactPayableMinor +
|
||||||
|
r.inKindReceivableMinor -
|
||||||
|
r.inKindPayableMinor,
|
||||||
|
missingPrice,
|
||||||
|
includesHidden: accounts.totals.includesHidden,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
|
Coins,
|
||||||
Users,
|
Users,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -130,6 +131,7 @@ export function rowTitle(item: TransactionListItem) {
|
|||||||
function baseTitle(item: TransactionListItem) {
|
function baseTitle(item: TransactionListItem) {
|
||||||
if (item.isAdjustment) return t.adjustment;
|
if (item.isAdjustment) return t.adjustment;
|
||||||
if (item.contact) return item.contact.name;
|
if (item.contact) return item.contact.name;
|
||||||
|
if (item.asset) return item.asset.name;
|
||||||
if (item.payeeName) return item.payeeName;
|
if (item.payeeName) return item.payeeName;
|
||||||
if (item.description) return item.description;
|
if (item.description) return item.description;
|
||||||
if (item.type === "transfer") return t.types.transfer;
|
if (item.type === "transfer") return t.types.transfer;
|
||||||
@@ -141,6 +143,10 @@ export function rowSubtitle(item: TransactionListItem) {
|
|||||||
const account = item.account?.name ?? item.toAccount?.name ?? "—";
|
const account = item.account?.name ?? item.toAccount?.name ?? "—";
|
||||||
return `${tr.contacts.kinds[item.contact.kind]} · ${account}`;
|
return `${tr.contacts.kinds[item.contact.kind]} · ${account}`;
|
||||||
}
|
}
|
||||||
|
if (item.asset)
|
||||||
|
return item.account
|
||||||
|
? t.list.transfer(item.account.name, item.asset.name)
|
||||||
|
: t.list.transfer(item.asset.name, item.toAccount?.name ?? "—");
|
||||||
if (item.type === "transfer")
|
if (item.type === "transfer")
|
||||||
return t.list.transfer(item.account?.name ?? "—", item.toAccount?.name ?? "—");
|
return t.list.transfer(item.account?.name ?? "—", item.toAccount?.name ?? "—");
|
||||||
const what = item.isAdjustment
|
const what = item.isAdjustment
|
||||||
@@ -183,7 +189,14 @@ function DetailDialog({
|
|||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title={d.title}
|
title={d.title}
|
||||||
footer={
|
footer={
|
||||||
detail && canWrite ? (
|
detail && canWrite && detail.asset ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/birikim/${detail.asset.id}`}>
|
||||||
|
<Coins />
|
||||||
|
{tr.assets.open}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : detail && canWrite ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="destructive" onClick={() => onDelete(detail)}>
|
<Button variant="destructive" onClick={() => onDelete(detail)}>
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
@@ -239,6 +252,14 @@ function DetailDialog({
|
|||||||
</Link>,
|
</Link>,
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
{detail.asset
|
||||||
|
? row(
|
||||||
|
tr.assets.asset,
|
||||||
|
<Link href={`/birikim/${detail.asset.id}`} className="hover:underline">
|
||||||
|
{detail.asset.name}
|
||||||
|
</Link>,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
{detail.account
|
{detail.account
|
||||||
? row(
|
? row(
|
||||||
detail.type === "transfer" ? t.fields.fromAccount : t.fields.account,
|
detail.type === "transfer" ? t.fields.fromAccount : t.fields.account,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { writeAudit } from "@/lib/audit";
|
|||||||
import { getDb } from "@/lib/db/mongo";
|
import { getDb } from "@/lib/db/mongo";
|
||||||
import { toObjectId } from "@/lib/object-id";
|
import { toObjectId } from "@/lib/object-id";
|
||||||
import { AccountModel } from "@/models/account";
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { AssetModel } from "@/models/asset";
|
||||||
import { ContactModel } from "@/models/contact";
|
import { ContactModel } from "@/models/contact";
|
||||||
import { CategoryModel } from "@/models/category";
|
import { CategoryModel } from "@/models/category";
|
||||||
import { PayeeModel } from "@/models/payee";
|
import { PayeeModel } from "@/models/payee";
|
||||||
@@ -70,7 +71,7 @@ export async function loadContext(
|
|||||||
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);
|
await postDueInstallments(m.householdId);
|
||||||
const [accounts, categories, contacts] = await Promise.all([
|
const [accounts, categories, contacts, assets] = 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 })
|
||||||
.lean(),
|
.lean(),
|
||||||
@@ -89,6 +90,9 @@ export async function loadContext(
|
|||||||
ContactModel.find({ householdId: m.householdId })
|
ContactModel.find({ householdId: m.householdId })
|
||||||
.select({ name: 1, isPrivate: 1, ownerUserId: 1, archivedAt: 1 })
|
.select({ name: 1, isPrivate: 1, ownerUserId: 1, archivedAt: 1 })
|
||||||
.lean(),
|
.lean(),
|
||||||
|
AssetModel.find({ householdId: m.householdId })
|
||||||
|
.select({ name: 1, assetKey: 1, isPrivate: 1, ownerUserId: 1, deletedAt: 1 })
|
||||||
|
.lean(),
|
||||||
]);
|
]);
|
||||||
const accountMap = new Map<string, AccountInfo>(
|
const accountMap = new Map<string, AccountInfo>(
|
||||||
accounts.map((a) => [
|
accounts.map((a) => [
|
||||||
@@ -143,6 +147,19 @@ export async function loadContext(
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const hiddenContactIds = [...contactMap.values()].filter((c) => c.hidden).map((c) => c.id);
|
const hiddenContactIds = [...contactMap.values()].filter((c) => c.hidden).map((c) => c.id);
|
||||||
|
const assetMap = new Map(
|
||||||
|
assets.map((a) => [
|
||||||
|
String(a._id),
|
||||||
|
{
|
||||||
|
id: a._id,
|
||||||
|
name: a.name,
|
||||||
|
assetKey: a.assetKey,
|
||||||
|
deleted: Boolean(a.deletedAt),
|
||||||
|
hidden: Boolean(a.isPrivate) && String(a.ownerUserId) !== userId,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const hiddenAssetIds = [...assetMap.values()].filter((a) => a.hidden).map((a) => a.id);
|
||||||
return {
|
return {
|
||||||
m,
|
m,
|
||||||
accountMap,
|
accountMap,
|
||||||
@@ -150,13 +167,15 @@ export async function loadContext(
|
|||||||
contactMap,
|
contactMap,
|
||||||
hiddenAccountIds,
|
hiddenAccountIds,
|
||||||
hiddenContactIds,
|
hiddenContactIds,
|
||||||
|
assetMap,
|
||||||
|
hiddenAssetIds,
|
||||||
flatCategories: flat,
|
flatCategories: flat,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
|
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
|
||||||
|
|
||||||
/** Gizli hesaplara veya gizli carilere dokunan işlemleri dışlayan temel filtre. */
|
/** Gizli hesaplara, gizli carilere veya gizli varlıklara dokunan işlemleri dışlayan temel filtre. */
|
||||||
export 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) {
|
||||||
@@ -164,6 +183,7 @@ export function baseFilter(ctx: Ctx): Record<string, unknown> {
|
|||||||
f.toAccountId = { $nin: ctx.hiddenAccountIds };
|
f.toAccountId = { $nin: ctx.hiddenAccountIds };
|
||||||
}
|
}
|
||||||
if (ctx.hiddenContactIds.length) f.contactId = { $nin: ctx.hiddenContactIds };
|
if (ctx.hiddenContactIds.length) f.contactId = { $nin: ctx.hiddenContactIds };
|
||||||
|
if (ctx.hiddenAssetIds.length) f.assetId = { $nin: ctx.hiddenAssetIds };
|
||||||
return f;
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,6 +493,7 @@ export async function updateTransaction(
|
|||||||
if (current.isAdjustment) return fail(t.adjustmentReadOnly);
|
if (current.isAdjustment) return fail(t.adjustmentReadOnly);
|
||||||
// Cari hareketi cari sayfasındaki formla düzenlenir (tür ve yön cariye özgü).
|
// Cari hareketi cari sayfasındaki formla düzenlenir (tür ve yön cariye özgü).
|
||||||
if (current.contactId) return fail(t.contactEditHint);
|
if (current.contactId) return fail(t.contactEditHint);
|
||||||
|
if (current.assetId) return fail(t.assetLocked);
|
||||||
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 {
|
const d = built.data as {
|
||||||
@@ -534,6 +555,8 @@ export async function setTransactionDeleted(
|
|||||||
if (!current || Boolean(current.deletedAt) === deleted) return fail(tr.common.notFound);
|
if (!current || Boolean(current.deletedAt) === deleted) return fail(tr.common.notFound);
|
||||||
// Aynî borç ödemesi kaydın ödeme geçmişinin parçasıdır; tek başına silinmez.
|
// Aynî borç ödemesi kaydın ödeme geçmişinin parçasıdır; tek başına silinmez.
|
||||||
if (current.inKindRole === "payment") return fail(t.inKindPaymentLocked);
|
if (current.inKindRole === "payment") return fail(t.inKindPaymentLocked);
|
||||||
|
// Varlık alım/satışı hareketin parçasıdır; hareket Birikim sayfasından silinir.
|
||||||
|
if (current.assetId) return fail(t.assetLocked);
|
||||||
const actor = new Types.ObjectId(actorId);
|
const actor = new Types.ObjectId(actorId);
|
||||||
await TransactionModel.updateOne(
|
await TransactionModel.updateOne(
|
||||||
{ _id: current._id, householdId: ctx.m.householdId },
|
{ _id: current._id, householdId: ctx.m.householdId },
|
||||||
@@ -602,6 +625,8 @@ export type TransactionListItem = {
|
|||||||
toAccount: { id: string; name: string } | null;
|
toAccount: { id: string; name: string } | null;
|
||||||
/** Cari transferi: kişi ve hareket türü. */
|
/** Cari transferi: kişi ve hareket türü. */
|
||||||
contact: { id: string; name: string; kind: ContactEntryKind } | null;
|
contact: { id: string; name: string; kind: ContactEntryKind } | null;
|
||||||
|
/** Varlık alımı/satışı: karşı taraf varlık. */
|
||||||
|
asset: { id: string; name: string } | null;
|
||||||
category: { id: string; name: string; path: string; icon: IconKey; color: Swatch } | null;
|
category: { id: string; name: string; path: string; icon: IconKey; color: Swatch } | null;
|
||||||
splitCount: number;
|
splitCount: number;
|
||||||
payeeName: string;
|
payeeName: string;
|
||||||
@@ -683,6 +708,9 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
|
|||||||
kind: tx.contactKind,
|
kind: tx.contactKind,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
asset: tx.assetId
|
||||||
|
? { id: String(tx.assetId), name: ctx.assetMap.get(String(tx.assetId))?.name ?? "—" }
|
||||||
|
: null,
|
||||||
installment: tx.installment?.planId
|
installment: tx.installment?.planId
|
||||||
? {
|
? {
|
||||||
planId: String(tx.installment.planId),
|
planId: String(tx.installment.planId),
|
||||||
|
|||||||
+164
@@ -364,6 +364,7 @@ export const tr = {
|
|||||||
cancelInstallments: "Kalan taksitleri iptal et",
|
cancelInstallments: "Kalan taksitleri iptal et",
|
||||||
contactEditHint: "Cari hareketi cari sayfasından düzenlenir.",
|
contactEditHint: "Cari hareketi cari sayfasından düzenlenir.",
|
||||||
inKindPaymentLocked: "Aynî borç ödemesi silinemez; ödeme cari sayfasındaki kayda bağlıdır.",
|
inKindPaymentLocked: "Aynî borç ödemesi silinemez; ödeme cari sayfasındaki kayda bağlıdır.",
|
||||||
|
assetLocked: "Varlık alım/satış işlemi Birikim sayfasındaki hareketten yönetilir.",
|
||||||
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",
|
||||||
@@ -638,6 +639,169 @@ export const tr = {
|
|||||||
price: "Alış veya satış fiyatı girin",
|
price: "Alış veya satış fiyatı girin",
|
||||||
amount: "Tutar girin",
|
amount: "Tutar girin",
|
||||||
account: "Hesap seçin",
|
account: "Hesap seçin",
|
||||||
|
assetOnlyWithAsset: "Varlık bağlantısı yalnızca aynı varlıkla ödemede kullanılır.",
|
||||||
|
noAmountWithAsset: "Varlıktan ödemede TL tutar girilmez.",
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
debt: "Birikimdeki varlıktan düş",
|
||||||
|
receivable: "Birikimdeki varlığa ekle",
|
||||||
|
debtHint: "Ödenen miktar seçilen varlıktan ortalama maliyetle çıkar.",
|
||||||
|
receivableHint:
|
||||||
|
"Tahsil edilen miktar seçilen varlığa eklenir; maliyet borç tarihindeki birim fiyattır.",
|
||||||
|
asset: "Varlık",
|
||||||
|
none: "Bu türde varlık yok; önce Birikim sayfasından ekleyin.",
|
||||||
|
movementNote: (name: string) => `Aynî kayıt: ${name}`,
|
||||||
|
assetMismatch: "Seçilen varlık bu kaydın varlık türünde değil.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
title: "Birikim",
|
||||||
|
description:
|
||||||
|
"Altın, döviz ve ziynet birikimleri: alım lotları, satış ve çıkışlar, ortalama maliyet ve kâr/zarar. Değerleme alış fiyatıyla.",
|
||||||
|
new: "Varlık ekle",
|
||||||
|
asset: "Varlık",
|
||||||
|
open: "Varlığa git",
|
||||||
|
createTitle: "Yeni varlık",
|
||||||
|
editTitle: "Varlığı düzenle",
|
||||||
|
empty: "Henüz varlık yok. Bir varlık ekleyip ilk alımı girin.",
|
||||||
|
closedGroup: "Elde kalmayanlar",
|
||||||
|
privateBadge: "Yalnızca ben",
|
||||||
|
fields: {
|
||||||
|
asset: "Varlık türü",
|
||||||
|
name: "Ad",
|
||||||
|
namePlaceholder: "Ör. Gram altın birikimi",
|
||||||
|
note: "Not",
|
||||||
|
isPrivate: "Yalnızca ben görebilirim",
|
||||||
|
isPrivateHint: "İşaretlenirse bu varlık ve hareketleri hanedeki diğer üyelere görünmez.",
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
quantity: "Eldeki miktar",
|
||||||
|
avgCost: "Ortalama maliyet",
|
||||||
|
cost: "Maliyet",
|
||||||
|
currentValue: "Güncel değer",
|
||||||
|
unrealized: "Gerçekleşmemiş K/Z",
|
||||||
|
realized: "Gerçekleşmiş K/Z",
|
||||||
|
invested: "Yatırılan",
|
||||||
|
proceeds: "Satış geliri",
|
||||||
|
noPrice: "Fiyat yok",
|
||||||
|
price: (price: string, date: string) => `Güncel alış fiyatı ${price} · ${date}`,
|
||||||
|
perUnit: (unit: string) => `/ ${unit}`,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
value: "Toplam güncel değer",
|
||||||
|
cost: "Toplam maliyet",
|
||||||
|
unrealized: "Gerçekleşmemiş K/Z",
|
||||||
|
realized: "Gerçekleşmiş K/Z",
|
||||||
|
missingPrice: (n: number) => `${n} varlığın fiyatı yok; toplam değere katılmadı.`,
|
||||||
|
},
|
||||||
|
kinds: {
|
||||||
|
buy: "Alım",
|
||||||
|
gift_in: "Hediye / miras geldi",
|
||||||
|
sell: "Satış",
|
||||||
|
gift_out: "Hediye / çıkış",
|
||||||
|
},
|
||||||
|
kindHints: {
|
||||||
|
buy: "Para seçilen hesaptan çıkar (transfer; gider sayılmaz).",
|
||||||
|
gift_in: "Hesap hareketi oluşmaz; maliyet o günkü değerdir.",
|
||||||
|
sell: "Satış tutarı seçilen hesaba girer; ortalama maliyete göre kâr/zarar yazılır.",
|
||||||
|
gift_out: "Hesap hareketi oluşmaz; ortalama maliyetle elden çıkar.",
|
||||||
|
},
|
||||||
|
movement: {
|
||||||
|
add: "Hareket ekle",
|
||||||
|
title: (kind: string, name: string) => `${kind} · ${name}`,
|
||||||
|
date: "Tarih",
|
||||||
|
quantity: "Miktar",
|
||||||
|
unitPrice: "Birim fiyat (TL)",
|
||||||
|
amount: "Toplam tutar (TL)",
|
||||||
|
priceHint:
|
||||||
|
"Tutar veya birim fiyattan birini girin; ikisi de boşsa fiyat tablosu kullanılır (alımda satış, satışta alış fiyatı).",
|
||||||
|
giftValueHint: "Boş bırakılırsa fiyat tablosundaki o günkü alış fiyatı kullanılır.",
|
||||||
|
account: "Hesap",
|
||||||
|
accountNone: "— Hesap yok —",
|
||||||
|
accountHint: "Hesap seçilmezse hesap hareketi oluşmaz.",
|
||||||
|
storage: "Saklama yeri",
|
||||||
|
storagePlaceholder: "Ör. ev kasası, banka kiralık kasa",
|
||||||
|
note: "Not",
|
||||||
|
held: (q: string) => `Eldeki: ${q}`,
|
||||||
|
},
|
||||||
|
history: "Hareketler",
|
||||||
|
noMovements: "Henüz hareket yok.",
|
||||||
|
report: {
|
||||||
|
title: "Dönemsel rapor",
|
||||||
|
ranges: { "3": "3 ay", "6": "6 ay", "12": "12 ay", tum: "Tümü" },
|
||||||
|
rangeLabel: "Dönem",
|
||||||
|
period: (from: string, to: string) => `${from} – ${to}`,
|
||||||
|
startValue: "Dönem başı değer",
|
||||||
|
invested: "Yatırılan",
|
||||||
|
endValue: "Dönem sonu değer",
|
||||||
|
profit: "Dönem kâr/zararı",
|
||||||
|
proceeds: "Satış geliri",
|
||||||
|
giftIn: "Gelen hediye",
|
||||||
|
giftOut: "Çıkan hediye",
|
||||||
|
formula:
|
||||||
|
"Kâr/zarar = dönem sonu değer + satış + çıkan hediye − dönem başı değer − yatırılan − gelen hediye.",
|
||||||
|
value: "Güncel değer",
|
||||||
|
cost: "Maliyet",
|
||||||
|
chartLabel: "Aylık değer ve maliyet",
|
||||||
|
table: "Tablo olarak göster",
|
||||||
|
date: "Tarih",
|
||||||
|
noPrice: "Fiyat yok",
|
||||||
|
excluded: (n: number) => `${n} varlık fiyatı olmadığı için rapora katılmadı.`,
|
||||||
|
empty: "Bu dönemde gösterilecek varlık yok.",
|
||||||
|
},
|
||||||
|
netWorth: {
|
||||||
|
title: "Net varlık",
|
||||||
|
description: "Hesaplar + birikim + alacaklar − borçlar (TL ve aynî).",
|
||||||
|
accounts: "Hesap bakiyeleri",
|
||||||
|
cardDebt: "Kart ve KMH borçları",
|
||||||
|
assets: "Birikim (altın, döviz)",
|
||||||
|
contactReceivable: "Cari alacaklar",
|
||||||
|
contactPayable: "Cari borçlar",
|
||||||
|
inKindReceivable: "Aynî alacaklar",
|
||||||
|
inKindPayable: "Aynî borçlar",
|
||||||
|
total: "Net varlık",
|
||||||
|
missingPrice: (n: number) => `${n} kalemin fiyatı yok; toplama katılmadı.`,
|
||||||
|
includesHidden: "Hane ayarı gereği diğer üyelerin gizli hesap bakiyeleri dahil.",
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
date: "Tarih",
|
||||||
|
kind: "Hareket",
|
||||||
|
quantity: "Miktar",
|
||||||
|
unitPrice: "Birim fiyat",
|
||||||
|
amount: "Tutar",
|
||||||
|
result: "K/Z",
|
||||||
|
balance: "Kalan",
|
||||||
|
},
|
||||||
|
deleteMovement: "Hareketi sil",
|
||||||
|
deleteMovementTitle: "Hareket silinsin mi?",
|
||||||
|
deleteMovementDescription:
|
||||||
|
"Hareket ve bağlı hesap işlemi silinir; ortalama maliyet ve kâr/zarar yeniden hesaplanır.",
|
||||||
|
delete: "Varlığı sil",
|
||||||
|
deleteTitle: "Varlık silinsin mi?",
|
||||||
|
deleteDescription: "Yalnızca hareketi olmayan varlık silinebilir.",
|
||||||
|
prices: "Fiyat tablosu",
|
||||||
|
back: "Birikim",
|
||||||
|
txDescription: {
|
||||||
|
buy: (label: string, name: string) => `Varlık alımı: ${label} (${name})`,
|
||||||
|
sell: (label: string, name: string) => `Varlık satışı: ${label} (${name})`,
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
saved: "Varlık kaydedildi.",
|
||||||
|
movement: "Hareket kaydedildi.",
|
||||||
|
movementDeleted: "Hareket silindi.",
|
||||||
|
deleted: "Varlık silindi.",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
noPrice: "Bu tarih için fiyat yok; tutar veya birim fiyat girin.",
|
||||||
|
insufficient: "Bu tarihte eldeki miktar yetersiz.",
|
||||||
|
deleteBreaks: "Bu hareket silinirse sonraki bir satış/çıkış eldeki miktarı aşar.",
|
||||||
|
hasMovements: "Hareketi olan varlık silinemez; önce hareketleri silin.",
|
||||||
|
assetKeyLocked: "Hareketi olan varlığın türü değiştirilemez.",
|
||||||
|
futureDate: "Gelecek tarihli hareket girilemez.",
|
||||||
|
privateOwner: "Yalnızca sahibi gizliliği değiştirebilir.",
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
noAccountForGift: "Hediye hareketinde hesap seçilmez.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
prices: {
|
prices: {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
import { MOVEMENT_KINDS } from "@/shared/portfolio";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Varlık hareketi (lot): alım, gelen hediye, satış, hediye/çıkış. Alım ve satış bir hesaba
|
||||||
|
* bağlıysa transfer işlemi oluşturulur (`transactionId`; gelir/gider raporlarına girmez).
|
||||||
|
* Sıra: tarih, sonra oluşturulma anı.
|
||||||
|
*/
|
||||||
|
const assetMovementSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
assetId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
kind: { type: String, enum: MOVEMENT_KINDS, required: true },
|
||||||
|
/** "YYYY-MM-DD" (İstanbul). */
|
||||||
|
date: { type: String, required: true },
|
||||||
|
/** Binde bir. */
|
||||||
|
quantity: { type: Number, required: true, min: 1 },
|
||||||
|
unitPriceMinor: { type: Number, default: null },
|
||||||
|
/** Alımda ödenen, satışta alınan, gelen hediyede değer; çıkışta 0. */
|
||||||
|
amountMinor: { type: Number, default: 0, min: 0 },
|
||||||
|
accountId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
transactionId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
/** Saklama yeri (kasa, banka kiralık kasası …). */
|
||||||
|
storage: { type: String, default: "", maxlength: 120 },
|
||||||
|
note: { type: String, default: "", maxlength: 300 },
|
||||||
|
/** Aynî borç/alacak ödemesinden gelen hareket (Faz 9b). */
|
||||||
|
inKindDebtId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
deletedAt: { type: Date, default: null },
|
||||||
|
seedRef: { type: String, default: null },
|
||||||
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true, collection: "assetMovements" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assetMovementSchema.index({ householdId: 1, assetId: 1, deletedAt: 1, date: 1, createdAt: 1 });
|
||||||
|
assetMovementSchema.index(
|
||||||
|
{ householdId: 1, seedRef: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AssetMovement = InferSchemaType<typeof assetMovementSchema>;
|
||||||
|
|
||||||
|
export const AssetMovementModel: Model<AssetMovement> =
|
||||||
|
(models.AssetMovement as Model<AssetMovement> | undefined) ??
|
||||||
|
model<AssetMovement>("AssetMovement", assetMovementSchema);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
import { ASSET_KEYS } from "@/shared/assets";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Varlık (Bölüm 6.6): bir varlık türünde tutulan birikim, ör. "Gram altın birikimi" veya
|
||||||
|
* "Düğün ziynetleri". Miktar ve maliyet `assetMovements`ten hesaplanır (ağırlıklı ortalama).
|
||||||
|
* "Yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür.
|
||||||
|
*/
|
||||||
|
const assetSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
assetKey: { type: String, enum: ASSET_KEYS, required: true },
|
||||||
|
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||||
|
note: { type: String, default: "", maxlength: 500 },
|
||||||
|
isPrivate: { type: Boolean, default: false },
|
||||||
|
ownerUserId: { type: Schema.Types.ObjectId, 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: "assets" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assetSchema.index({ householdId: 1, deletedAt: 1, name: 1 });
|
||||||
|
assetSchema.index(
|
||||||
|
{ householdId: 1, seedRef: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Asset = InferSchemaType<typeof assetSchema>;
|
||||||
|
|
||||||
|
export const AssetModel: Model<Asset> =
|
||||||
|
(models.Asset as Model<Asset> | undefined) ?? model<Asset>("Asset", assetSchema);
|
||||||
@@ -33,6 +33,8 @@ export const AUDIT_ACTIONS = [
|
|||||||
"inkind.payment",
|
"inkind.payment",
|
||||||
"inkind.close",
|
"inkind.close",
|
||||||
"inkind.delete",
|
"inkind.delete",
|
||||||
|
"asset.delete",
|
||||||
|
"asset.movement.delete",
|
||||||
] as const;
|
] as const;
|
||||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ const transactionSchema = new Schema(
|
|||||||
*/
|
*/
|
||||||
inKindDebtId: { type: Schema.Types.ObjectId, default: null },
|
inKindDebtId: { type: Schema.Types.ObjectId, default: null },
|
||||||
inKindRole: { type: String, enum: ["contribution", "payment", null], default: null },
|
inKindRole: { type: String, enum: ["contribution", "payment", null], default: null },
|
||||||
|
/**
|
||||||
|
* Varlık alımı (hesap → varlık) veya satışı (varlık → hesap): karşı hesap boş transfer.
|
||||||
|
* Hareketi varlık sayfasından yönetilir; görünürlük varlığa bağlıdır.
|
||||||
|
*/
|
||||||
|
assetId: { type: Schema.Types.ObjectId, default: null },
|
||||||
/**
|
/**
|
||||||
* Mutabakatı yapılmış hesaplar (Bölüm 6.1). Transfer iki hesaba dokunduğu için hesap bazında
|
* 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.
|
* tutulur. Tutar, tarih, tür veya hesap değişirse işaret kalkar.
|
||||||
@@ -88,6 +93,7 @@ transactionSchema.index({ householdId: 1, effectiveCategoryIds: 1, date: -1 });
|
|||||||
transactionSchema.index({ householdId: 1, tags: 1 });
|
transactionSchema.index({ householdId: 1, tags: 1 });
|
||||||
transactionSchema.index({ householdId: 1, contactId: 1, date: 1 }, { sparse: true });
|
transactionSchema.index({ householdId: 1, contactId: 1, date: 1 }, { sparse: true });
|
||||||
transactionSchema.index({ "installment.planId": 1 }, { sparse: true });
|
transactionSchema.index({ "installment.planId": 1 }, { sparse: true });
|
||||||
|
transactionSchema.index({ householdId: 1, assetId: 1 }, { sparse: true });
|
||||||
transactionSchema.index(
|
transactionSchema.index(
|
||||||
{ recurringRuleId: 1, occurrenceIndex: 1 },
|
{ recurringRuleId: 1, occurrenceIndex: 1 },
|
||||||
{ unique: true, partialFilterExpression: { recurringRuleId: { $type: "objectId" } } },
|
{ unique: true, partialFilterExpression: { recurringRuleId: { $type: "objectId" } } },
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
formatBp,
|
||||||
|
holdingSummary,
|
||||||
|
periodPoints,
|
||||||
|
periodReport,
|
||||||
|
runMovements,
|
||||||
|
sumReports,
|
||||||
|
type DatedMovement,
|
||||||
|
type MovementInput,
|
||||||
|
} from "./portfolio";
|
||||||
|
|
||||||
|
const buy = (quantity: number, amountMinor: number): MovementInput => ({
|
||||||
|
kind: "buy",
|
||||||
|
quantity,
|
||||||
|
amountMinor,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ağırlıklı ortalama maliyet", () => {
|
||||||
|
it("örnek senaryo: 10 gr 25.000 TL'ye, her ay ekleme", () => {
|
||||||
|
// 10 gr × 2.500, 2 gr × 2.600, 2 gr × 2.700 → 14 gr, 35.600 TL; ort. 2.542,857…
|
||||||
|
const run = runMovements([buy(10_000, 25_000_00), buy(2_000, 5_200_00), buy(2_000, 5_400_00)]);
|
||||||
|
expect(run.quantity).toBe(14_000);
|
||||||
|
expect(run.costMinor).toBe(35_600_00);
|
||||||
|
expect(run.investedMinor).toBe(35_600_00);
|
||||||
|
const s = holdingSummary(run, 3_000_00);
|
||||||
|
expect(s.avgCostMinor).toBe(2_542_86);
|
||||||
|
expect(s.currentValueMinor).toBe(42_000_00);
|
||||||
|
expect(s.unrealizedMinor).toBe(6_400_00);
|
||||||
|
// 6.400 / 35.600 = %17,98
|
||||||
|
expect(s.unrealizedBp).toBe(1798);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("satış: ortalama maliyetle düşer, gerçekleşmiş kâr/zarar", () => {
|
||||||
|
const run = runMovements([
|
||||||
|
buy(10_000, 30_000_00),
|
||||||
|
buy(10_000, 40_000_00),
|
||||||
|
{ kind: "sell", quantity: 5_000, amountMinor: 20_000_00 },
|
||||||
|
]);
|
||||||
|
// ort. 3.500 → 5 gr maliyet 17.500; satış 20.000 → +2.500
|
||||||
|
expect(run.steps[2]).toMatchObject({ costOutMinor: 17_500_00, realizedMinor: 2_500_00 });
|
||||||
|
expect(run.quantity).toBe(15_000);
|
||||||
|
expect(run.costMinor).toBe(52_500_00);
|
||||||
|
expect(run.proceedsMinor).toBe(20_000_00);
|
||||||
|
// Kalan ortalama değişmez.
|
||||||
|
expect(holdingSummary(run, null).avgCostMinor).toBe(3_500_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hediye çıkışı K/Z yazmaz; gelen hediye yatırım sayılmaz; tamamı çıkınca maliyet sıfır", () => {
|
||||||
|
const run = runMovements([
|
||||||
|
buy(3_000, 9_000_00),
|
||||||
|
{ kind: "gift_in", quantity: 1_000, amountMinor: 3_300_00 },
|
||||||
|
{ kind: "gift_out", quantity: 2_000, amountMinor: 0 },
|
||||||
|
{ kind: "sell", quantity: 2_000, amountMinor: 7_000_00 },
|
||||||
|
]);
|
||||||
|
expect(run.investedMinor).toBe(9_000_00);
|
||||||
|
expect(run.giftInMinor).toBe(3_300_00);
|
||||||
|
// ort. 3.075 → çıkış 6.150, kalan 6.150; satış tümü → K/Z 850
|
||||||
|
expect(run.giftOutCostMinor).toBe(6_150_00);
|
||||||
|
expect(run.realizedMinor).toBe(850_00);
|
||||||
|
expect(run.quantity).toBe(0);
|
||||||
|
expect(run.costMinor).toBe(0);
|
||||||
|
const s = holdingSummary(run, 3_500_00);
|
||||||
|
expect(s).toMatchObject({ avgCostMinor: null, currentValueMinor: 0, unrealizedBp: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("eldekinden fazla çıkış işaretlenir", () => {
|
||||||
|
const run = runMovements([
|
||||||
|
buy(1_000, 100_00),
|
||||||
|
{ kind: "sell", quantity: 2_000, amountMinor: 1 },
|
||||||
|
]);
|
||||||
|
expect(run.negativeAt).toBe(1);
|
||||||
|
expect(runMovements([buy(1_000, 100_00)]).negativeAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("oran biçimi", () => {
|
||||||
|
expect(formatBp(1798)).toBe("+%17,98");
|
||||||
|
expect(formatBp(-350)).toBe("−%3,5");
|
||||||
|
expect(formatBp(0)).toBe("%0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dönemsel rapor", () => {
|
||||||
|
it("noktalar: başlangıç, ay sonları, bitiş", () => {
|
||||||
|
expect(periodPoints("2026-05-06", "2026-08-15")).toEqual([
|
||||||
|
"2026-05-06",
|
||||||
|
"2026-05-31",
|
||||||
|
"2026-06-30",
|
||||||
|
"2026-07-31",
|
||||||
|
"2026-08-15",
|
||||||
|
]);
|
||||||
|
expect(periodPoints("2026-05-31", "2026-06-30")).toEqual(["2026-05-31", "2026-06-30"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Örnek: 10 gr'ı 25.000'e aldım, her ay 1 gr ekledim; 6 ay sonra ne kazandım?
|
||||||
|
const prices: Record<string, number> = {
|
||||||
|
"2026-01-01": 2_500_00,
|
||||||
|
"2026-02-01": 2_600_00,
|
||||||
|
"2026-03-01": 2_700_00,
|
||||||
|
"2026-07-01": 3_000_00,
|
||||||
|
};
|
||||||
|
const priceAt = (date: string) => {
|
||||||
|
const keys = Object.keys(prices)
|
||||||
|
.filter((k) => k <= date)
|
||||||
|
.sort();
|
||||||
|
return keys.length ? prices[keys.at(-1)!]! : null;
|
||||||
|
};
|
||||||
|
const ms: DatedMovement[] = [
|
||||||
|
{ kind: "buy", date: "2026-01-01", quantity: 10_000, amountMinor: 25_000_00 },
|
||||||
|
{ kind: "buy", date: "2026-02-01", quantity: 1_000, amountMinor: 2_600_00 },
|
||||||
|
{ kind: "buy", date: "2026-03-01", quantity: 1_000, amountMinor: 2_700_00 },
|
||||||
|
{ kind: "gift_out", date: "2026-07-01", quantity: 1_000, amountMinor: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("örnek senaryo: yatırılan, dönem sonu değer, kâr", () => {
|
||||||
|
const r = periodReport(ms, priceAt, "2026-01-01", "2026-07-02");
|
||||||
|
expect(r.startValueMinor).toBe(0);
|
||||||
|
expect(r.investedMinor).toBe(30_300_00);
|
||||||
|
// 11 gr × 3.000 = 33.000; çıkan 1 gr o günkü değeri 3.000
|
||||||
|
expect(r.endValueMinor).toBe(33_000_00);
|
||||||
|
expect(r.giftOutMinor).toBe(3_000_00);
|
||||||
|
expect(r.profitMinor).toBe(5_700_00);
|
||||||
|
expect(r.profitBp).toBe(1881);
|
||||||
|
expect(r.points.map((p) => p.valueMinor)).toEqual([
|
||||||
|
0, 25_000_00, 28_600_00, 32_400_00, 32_400_00, 32_400_00, 32_400_00, 33_000_00,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("başlangıçta eldeki değer dönem içi kârdan düşülür; fiyat yoksa null", () => {
|
||||||
|
const r = periodReport(ms, priceAt, "2026-03-15", "2026-07-02");
|
||||||
|
expect(r.startValueMinor).toBe(32_400_00);
|
||||||
|
expect(r.investedMinor).toBe(0);
|
||||||
|
expect(r.profitMinor).toBe(33_000_00 + 3_000_00 - 32_400_00);
|
||||||
|
expect(periodReport(ms, () => null, "2026-01-01", "2026-07-02").profitMinor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toplam", () => {
|
||||||
|
const a = periodReport(ms, priceAt, "2026-01-01", "2026-07-02");
|
||||||
|
const t = sumReports([a, a], "2026-01-01", "2026-07-02");
|
||||||
|
expect(t.investedMinor).toBe(60_600_00);
|
||||||
|
expect(t.profitMinor).toBe(11_400_00);
|
||||||
|
expect(t.points.at(-1)!.valueMinor).toBe(66_000_00);
|
||||||
|
expect(sumReports([], "2026-01-01", "2026-01-31").points).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { QUANTITY_SCALE } from "./quantity";
|
||||||
|
import { addDays, addMonths } from "./recurrence";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Varlık takibi hesap motoru (Bölüm 6.6): ağırlıklı ortalama maliyet, gerçekleşmiş ve
|
||||||
|
* gerçekleşmemiş kâr/zarar. Miktarlar binde bir tamsayı, tutarlar kuruş.
|
||||||
|
*
|
||||||
|
* - Alım (`buy`): miktar ve maliyet artar; ödenen TL "yatırılan" sayılır.
|
||||||
|
* - Gelen hediye/miras (`gift_in`): miktar artar; maliyet = girildiği günkü değer (yatırım değil).
|
||||||
|
* - Satış (`sell`): ortalama maliyetle maliyet düşer; satış tutarı − düşen maliyet = gerçekleşmiş K/Z.
|
||||||
|
* - Hediye/çıkış (`gift_out`): ortalama maliyetle maliyet düşer; kâr/zarar yazılmaz.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MOVEMENT_KINDS = ["buy", "gift_in", "sell", "gift_out"] as const;
|
||||||
|
export type MovementKind = (typeof MOVEMENT_KINDS)[number];
|
||||||
|
|
||||||
|
export const isInflow = (k: MovementKind) => k === "buy" || k === "gift_in";
|
||||||
|
|
||||||
|
export type MovementInput = {
|
||||||
|
kind: MovementKind;
|
||||||
|
quantity: number;
|
||||||
|
/** Alımda ödenen, satışta alınan, gelen hediyede değer; çıkışta yok sayılır. */
|
||||||
|
amountMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MovementStep = {
|
||||||
|
/** Satış/çıkışta ortalama maliyetle düşülen maliyet. */
|
||||||
|
costOutMinor: number;
|
||||||
|
/** Yalnızca satışta: satış tutarı − düşülen maliyet. */
|
||||||
|
realizedMinor: number;
|
||||||
|
/** Hareketten sonraki miktar ve kalan maliyet. */
|
||||||
|
quantityAfter: number;
|
||||||
|
costAfterMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortfolioRun = {
|
||||||
|
steps: MovementStep[];
|
||||||
|
quantity: number;
|
||||||
|
/** Kalan miktarın maliyet esası. */
|
||||||
|
costMinor: number;
|
||||||
|
/** Alımlarda ödenen toplam TL. */
|
||||||
|
investedMinor: number;
|
||||||
|
/** Satışlardan alınan toplam TL. */
|
||||||
|
proceedsMinor: number;
|
||||||
|
realizedMinor: number;
|
||||||
|
/** Gelen hediyelerin girişteki değeri. */
|
||||||
|
giftInMinor: number;
|
||||||
|
/** Çıkışlarla düşen maliyet. */
|
||||||
|
giftOutCostMinor: number;
|
||||||
|
/** Miktarın eksiye düştüğü ilk hareketin sırası (geçerli dizide null). */
|
||||||
|
negativeAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hareketleri kronolojik sırada (çağıran sıralar) işler. */
|
||||||
|
export function runMovements(movements: MovementInput[]): PortfolioRun {
|
||||||
|
let quantity = 0;
|
||||||
|
let cost = 0;
|
||||||
|
const r: PortfolioRun = {
|
||||||
|
steps: [],
|
||||||
|
quantity: 0,
|
||||||
|
costMinor: 0,
|
||||||
|
investedMinor: 0,
|
||||||
|
proceedsMinor: 0,
|
||||||
|
realizedMinor: 0,
|
||||||
|
giftInMinor: 0,
|
||||||
|
giftOutCostMinor: 0,
|
||||||
|
negativeAt: null,
|
||||||
|
};
|
||||||
|
movements.forEach((m, i) => {
|
||||||
|
let costOut = 0;
|
||||||
|
let realized = 0;
|
||||||
|
if (isInflow(m.kind)) {
|
||||||
|
quantity += m.quantity;
|
||||||
|
cost += m.amountMinor;
|
||||||
|
if (m.kind === "buy") r.investedMinor += m.amountMinor;
|
||||||
|
else r.giftInMinor += m.amountMinor;
|
||||||
|
} else {
|
||||||
|
if (m.quantity > quantity) {
|
||||||
|
if (r.negativeAt === null) r.negativeAt = i;
|
||||||
|
costOut = cost;
|
||||||
|
} else {
|
||||||
|
costOut = m.quantity === quantity ? cost : Math.round((cost * m.quantity) / quantity);
|
||||||
|
}
|
||||||
|
quantity = Math.max(0, quantity - m.quantity);
|
||||||
|
cost -= costOut;
|
||||||
|
if (m.kind === "sell") {
|
||||||
|
realized = m.amountMinor - costOut;
|
||||||
|
r.proceedsMinor += m.amountMinor;
|
||||||
|
r.realizedMinor += realized;
|
||||||
|
} else r.giftOutCostMinor += costOut;
|
||||||
|
}
|
||||||
|
r.steps.push({
|
||||||
|
costOutMinor: costOut,
|
||||||
|
realizedMinor: realized,
|
||||||
|
quantityAfter: quantity,
|
||||||
|
costAfterMinor: cost,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
r.quantity = quantity;
|
||||||
|
r.costMinor = cost;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HoldingSummary = {
|
||||||
|
quantity: number;
|
||||||
|
costMinor: number;
|
||||||
|
/** Birim başına ağırlıklı ortalama maliyet (miktar 0 ise null). */
|
||||||
|
avgCostMinor: number | null;
|
||||||
|
/** Güncel alış fiyatıyla değer (fiyat yoksa null). */
|
||||||
|
currentValueMinor: number | null;
|
||||||
|
unrealizedMinor: number | null;
|
||||||
|
/** Gerçekleşmemiş K/Z oranı, baz puan (%12,34 → 1234); maliyet 0 ise null. */
|
||||||
|
unrealizedBp: number | null;
|
||||||
|
realizedMinor: number;
|
||||||
|
investedMinor: number;
|
||||||
|
proceedsMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Özet: `priceMinor` değerleme fiyatıdır (Faz 0: varlıklar alış fiyatıyla). */
|
||||||
|
export function holdingSummary(run: PortfolioRun, priceMinor: number | null): HoldingSummary {
|
||||||
|
const avgCostMinor =
|
||||||
|
run.quantity > 0 ? Math.round((run.costMinor * QUANTITY_SCALE) / run.quantity) : null;
|
||||||
|
const currentValueMinor =
|
||||||
|
priceMinor === null ? null : Math.round((run.quantity * priceMinor) / QUANTITY_SCALE);
|
||||||
|
const unrealizedMinor = currentValueMinor === null ? null : currentValueMinor - run.costMinor;
|
||||||
|
const unrealizedBp =
|
||||||
|
unrealizedMinor === null || run.costMinor <= 0
|
||||||
|
? null
|
||||||
|
: Math.round((unrealizedMinor * 10_000) / run.costMinor);
|
||||||
|
return {
|
||||||
|
quantity: run.quantity,
|
||||||
|
costMinor: run.costMinor,
|
||||||
|
avgCostMinor,
|
||||||
|
currentValueMinor,
|
||||||
|
unrealizedMinor,
|
||||||
|
unrealizedBp,
|
||||||
|
realizedMinor: run.realizedMinor,
|
||||||
|
investedMinor: run.investedMinor,
|
||||||
|
proceedsMinor: run.proceedsMinor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baz puanı "%12,34" / "−%3,5" biçiminde. */
|
||||||
|
export function formatBp(bp: number): string {
|
||||||
|
const s = (Math.abs(bp) / 100).toLocaleString("tr-TR", { maximumFractionDigits: 2 });
|
||||||
|
return `${bp < 0 ? "−" : bp > 0 ? "+" : ""}%${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dönemsel rapor (Bölüm 6.6) ---
|
||||||
|
|
||||||
|
export type DatedMovement = MovementInput & { date: string };
|
||||||
|
|
||||||
|
/** Dönem noktaları: başlangıç, aradaki ay sonları ve bitiş ("YYYY-MM-DD", artan, tekil). */
|
||||||
|
export function periodPoints(from: string, to: string): string[] {
|
||||||
|
const out = [from];
|
||||||
|
let monthEnd = addMonths(`${from.slice(0, 7)}-01`, 0, "last");
|
||||||
|
while (monthEnd < to) {
|
||||||
|
if (monthEnd > from) out.push(monthEnd);
|
||||||
|
monthEnd = addMonths(addDays(monthEnd, 1), 0, "last");
|
||||||
|
}
|
||||||
|
if (to > from) out.push(to);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PeriodPoint = { date: string; valueMinor: number | null; costMinor: number };
|
||||||
|
|
||||||
|
export type PeriodReport = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
/** Dönem başındaki (başlangıç gününden önceki hareketlerle) değer. */
|
||||||
|
startValueMinor: number | null;
|
||||||
|
endValueMinor: number | null;
|
||||||
|
/** Dönemdeki alımlarda ödenen. */
|
||||||
|
investedMinor: number;
|
||||||
|
/** Dönemdeki satışlardan alınan. */
|
||||||
|
proceedsMinor: number;
|
||||||
|
/** Dönemde gelen hediyelerin girişteki değeri. */
|
||||||
|
giftInMinor: number;
|
||||||
|
/** Dönemde çıkan hediyelerin o günkü değeri (fiyat yoksa maliyeti). */
|
||||||
|
giftOutMinor: number;
|
||||||
|
/**
|
||||||
|
* Dönem kârı: bitiş değeri + satış + çıkan hediye − başlangıç değeri − alım − gelen hediye.
|
||||||
|
* Fiyat eksikse null.
|
||||||
|
*/
|
||||||
|
profitMinor: number | null;
|
||||||
|
/** Kârın, dönemde bağlanan sermayeye (başlangıç + alım + gelen hediye) oranı (baz puan). */
|
||||||
|
profitBp: number | null;
|
||||||
|
points: PeriodPoint[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bir varlığın dönem raporu. `movements` kronolojik sıralı; `priceAt(date)` o tarihteki (veya
|
||||||
|
* öncesindeki en yakın) değerleme fiyatı (alış), yoksa null.
|
||||||
|
*/
|
||||||
|
export function periodReport(
|
||||||
|
movements: DatedMovement[],
|
||||||
|
priceAt: (date: string) => number | null,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): PeriodReport {
|
||||||
|
const valueAt = (date: string, inclusive: boolean) => {
|
||||||
|
const run = runMovements(movements.filter((m) => (inclusive ? m.date <= date : m.date < date)));
|
||||||
|
const price = priceAt(date);
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
value:
|
||||||
|
run.quantity === 0
|
||||||
|
? 0
|
||||||
|
: price === null
|
||||||
|
? null
|
||||||
|
: Math.round((run.quantity * price) / QUANTITY_SCALE),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const start = valueAt(from, false);
|
||||||
|
const end = valueAt(to, true);
|
||||||
|
const all = runMovements(movements);
|
||||||
|
let invested = 0;
|
||||||
|
let proceeds = 0;
|
||||||
|
let giftIn = 0;
|
||||||
|
let giftOut = 0;
|
||||||
|
movements.forEach((m, i) => {
|
||||||
|
if (m.date < from || m.date > to) return;
|
||||||
|
if (m.kind === "buy") invested += m.amountMinor;
|
||||||
|
else if (m.kind === "sell") proceeds += m.amountMinor;
|
||||||
|
else if (m.kind === "gift_in") giftIn += m.amountMinor;
|
||||||
|
else {
|
||||||
|
const price = priceAt(m.date);
|
||||||
|
giftOut +=
|
||||||
|
price === null
|
||||||
|
? all.steps[i]!.costOutMinor
|
||||||
|
: Math.round((m.quantity * price) / QUANTITY_SCALE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const profitMinor =
|
||||||
|
start.value === null || end.value === null
|
||||||
|
? null
|
||||||
|
: end.value + proceeds + giftOut - start.value - invested - giftIn;
|
||||||
|
const base = (start.value ?? 0) + invested + giftIn;
|
||||||
|
const points = periodPoints(from, to).map((date, i) => {
|
||||||
|
const p = i === 0 ? start : valueAt(date, true);
|
||||||
|
return { date, valueMinor: p.value, costMinor: p.run.costMinor };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
startValueMinor: start.value,
|
||||||
|
endValueMinor: end.value,
|
||||||
|
investedMinor: invested,
|
||||||
|
proceedsMinor: proceeds,
|
||||||
|
giftInMinor: giftIn,
|
||||||
|
giftOutMinor: giftOut,
|
||||||
|
profitMinor,
|
||||||
|
profitBp: profitMinor === null || base <= 0 ? null : Math.round((profitMinor * 10_000) / base),
|
||||||
|
points,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Birden çok varlığın raporlarını toplar (noktalar aynı tarihlerde). Eksik fiyatlı değerler atlanır. */
|
||||||
|
export function sumReports(reports: PeriodReport[], from: string, to: string): PeriodReport {
|
||||||
|
const add = (a: number | null, b: number | null) => (a === null || b === null ? null : a + b);
|
||||||
|
const points = periodPoints(from, to).map((date, i) => ({
|
||||||
|
date,
|
||||||
|
valueMinor: reports.reduce<number | null>((a, r) => add(a, r.points[i]?.valueMinor ?? null), 0),
|
||||||
|
costMinor: reports.reduce((a, r) => a + (r.points[i]?.costMinor ?? 0), 0),
|
||||||
|
}));
|
||||||
|
const sum = (k: "investedMinor" | "proceedsMinor" | "giftInMinor" | "giftOutMinor") =>
|
||||||
|
reports.reduce((a, r) => a + r[k], 0);
|
||||||
|
const startValueMinor = reports.reduce<number | null>((a, r) => add(a, r.startValueMinor), 0);
|
||||||
|
const endValueMinor = reports.reduce<number | null>((a, r) => add(a, r.endValueMinor), 0);
|
||||||
|
const profitMinor = reports.reduce<number | null>((a, r) => add(a, r.profitMinor), 0);
|
||||||
|
const base = (startValueMinor ?? 0) + sum("investedMinor") + sum("giftInMinor");
|
||||||
|
return {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
startValueMinor,
|
||||||
|
endValueMinor,
|
||||||
|
investedMinor: sum("investedMinor"),
|
||||||
|
proceedsMinor: sum("proceedsMinor"),
|
||||||
|
giftInMinor: sum("giftInMinor"),
|
||||||
|
giftOutMinor: sum("giftOutMinor"),
|
||||||
|
profitMinor,
|
||||||
|
profitBp: profitMinor === null || base <= 0 ? null : Math.round((profitMinor * 10_000) / base),
|
||||||
|
points,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "../../i18n/tr";
|
||||||
|
import { ASSET_KEYS } from "../assets";
|
||||||
|
import { parseMoney } from "../money";
|
||||||
|
import { MOVEMENT_KINDS } from "../portfolio";
|
||||||
|
import { parseQuantity } from "../quantity";
|
||||||
|
|
||||||
|
const v = tr.validation;
|
||||||
|
const a = tr.assets.validation;
|
||||||
|
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
|
||||||
|
const objectId = z.string().regex(/^[a-f0-9]{24}$/i, tr.transactions.validation.selectAccount);
|
||||||
|
const emptyToNull = (x: unknown) => (x === "" || x === undefined ? null : x);
|
||||||
|
|
||||||
|
/** Boş → null; dolu → pozitif kuruş. */
|
||||||
|
const optionalMoney = z.preprocess(
|
||||||
|
emptyToNull,
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((s, ctx) => {
|
||||||
|
const minor = parseMoney(s);
|
||||||
|
if (minor === null || minor <= 0) {
|
||||||
|
ctx.addIssue({ code: "custom", message: v.money });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return minor;
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Varlık (birikim kalemi) formu. */
|
||||||
|
export const assetFormSchema = z.object({
|
||||||
|
assetKey: z.enum(ASSET_KEYS),
|
||||||
|
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
|
||||||
|
note: z.string().trim().max(500).default(""),
|
||||||
|
isPrivate: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
export type AssetFormInput = z.input<typeof assetFormSchema>;
|
||||||
|
export type AssetForm = z.output<typeof assetFormSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Varlık hareketi. Tutar ve birim fiyat isteğe bağlı: tutar varsa birim fiyat ondan, yalnızca
|
||||||
|
* birim fiyat varsa tutar ondan hesaplanır; ikisi de boşsa fiyat tablosu kullanılır
|
||||||
|
* (alım → satış fiyatı, satış ve gelen hediye → alış fiyatı). Hesap yalnızca alım/satışta.
|
||||||
|
*/
|
||||||
|
export const movementFormSchema = z
|
||||||
|
.object({
|
||||||
|
kind: z.enum(MOVEMENT_KINDS),
|
||||||
|
date: ymd,
|
||||||
|
quantity: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((s, ctx) => {
|
||||||
|
const q = parseQuantity(s);
|
||||||
|
if (q === null || q <= 0) {
|
||||||
|
ctx.addIssue({ code: "custom", message: tr.inKind.validation.quantity });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
}),
|
||||||
|
unitPrice: optionalMoney,
|
||||||
|
amount: optionalMoney,
|
||||||
|
accountId: z.preprocess(emptyToNull, objectId.nullable()),
|
||||||
|
storage: z.string().trim().max(120).default(""),
|
||||||
|
note: z.string().trim().max(300).default(""),
|
||||||
|
})
|
||||||
|
.superRefine((x, ctx) => {
|
||||||
|
if ((x.kind === "gift_in" || x.kind === "gift_out") && x.accountId)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["accountId"], message: a.noAccountForGift });
|
||||||
|
})
|
||||||
|
.transform((x) =>
|
||||||
|
x.kind === "gift_out" ? { ...x, unitPrice: null, amount: null, accountId: null } : x,
|
||||||
|
);
|
||||||
|
export type MovementFormInput = z.input<typeof movementFormSchema>;
|
||||||
|
export type MovementForm = z.output<typeof movementFormSchema>;
|
||||||
@@ -101,9 +101,15 @@ export const paymentFormSchema = z
|
|||||||
quantity: z.preprocess(emptyToNull, quantity.nullable()),
|
quantity: z.preprocess(emptyToNull, quantity.nullable()),
|
||||||
amount: optionalMoney,
|
amount: optionalMoney,
|
||||||
accountId: z.preprocess(emptyToNull, objectId.nullable()),
|
accountId: z.preprocess(emptyToNull, objectId.nullable()),
|
||||||
|
/** Aynı varlıkla ödemede bağlanacak varlık (Faz 9b): borçta varlıktan düşer, alacakta eklenir. */
|
||||||
|
assetId: z.preprocess(emptyToNull, objectId.nullable()).default(null),
|
||||||
note: z.string().trim().max(300).default(""),
|
note: z.string().trim().max(300).default(""),
|
||||||
})
|
})
|
||||||
.superRefine((x, ctx) => {
|
.superRefine((x, ctx) => {
|
||||||
|
if (x.assetId && x.method !== "asset")
|
||||||
|
ctx.addIssue({ code: "custom", path: ["assetId"], message: k.assetOnlyWithAsset });
|
||||||
|
if (x.assetId && x.amount)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["amount"], message: k.noAmountWithAsset });
|
||||||
if (x.method === "asset" && x.quantity === null)
|
if (x.method === "asset" && x.quantity === null)
|
||||||
ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity });
|
ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity });
|
||||||
if (x.method === "cash") {
|
if (x.method === "cash") {
|
||||||
|
|||||||
Reference in New Issue
Block a user