Compare commits
10 Commits
v0.8.0
..
6de6d07c66
| Author | SHA1 | Date | |
|---|---|---|---|
| 6de6d07c66 | |||
| 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ış]
|
||||
|
||||
## [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
|
||||
|
||||
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.
|
||||
- `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.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
|
||||
|
||||
@@ -320,6 +320,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
||||
- **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır.
|
||||
- **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir.
|
||||
- **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir.
|
||||
- **Faz 9 kararları (2026-11-05):** 9a varlık lotları ve kâr/zarar, 9b dönemsel rapor ve net varlık; `v0.9.0` 9b sonunda. Varlıklar hane ortaktır; "yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür. Varlık alım/satışı hesaba bağlıysa transfer olarak kaydedilir (gelir/gider raporlarına girmez). Aynî kayıt "aynı varlıkla" kapanırken isteğe bağlı olarak varlıktan düşülür / varlığa eklenir.
|
||||
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
||||
|
||||
---
|
||||
|
||||
Generated
+372
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "butcem",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^7.0.2",
|
||||
"@tanstack/react-table": "^9.3.1",
|
||||
@@ -24,6 +24,7 @@
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"recharts": "^3.8.0",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "^4.23.0",
|
||||
"sharp": "^0.35.5",
|
||||
@@ -4051,6 +4052,46 @@
|
||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"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": {
|
||||
"version": "1.2.16",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -5022,6 +5126,12 @@
|
||||
"@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": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
@@ -6573,6 +6683,127 @@
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"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": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@@ -7140,6 +7377,18 @@
|
||||
"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": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
@@ -7658,6 +7907,12 @@
|
||||
"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": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
||||
@@ -8282,6 +8537,16 @@
|
||||
"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": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -8324,6 +8589,15 @@
|
||||
"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": {
|
||||
"version": "10.7.6",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz",
|
||||
@@ -10715,9 +10989,31 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
@@ -10803,6 +11099,51 @@
|
||||
"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": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -10856,6 +11197,12 @@
|
||||
"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": {
|
||||
"version": "2.0.0-next.7",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||
"engines": {
|
||||
@@ -42,6 +42,7 @@
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"recharts": "^3.8.0",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "^4.23.0",
|
||||
"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 m007 } from "./007-contacts";
|
||||
import { migration as m008 } from "./008-in-kind";
|
||||
import { migration as m009 } from "./009-assets";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** 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";
|
||||
|
||||
|
||||
@@ -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.accounts).toBe(7);
|
||||
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 HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
||||
});
|
||||
@@ -49,6 +50,7 @@ describe("seed", () => {
|
||||
installmentPlans: 0,
|
||||
contacts: 0,
|
||||
inKind: 0,
|
||||
assets: 0,
|
||||
});
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
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 { runMigrations } from "../migrations";
|
||||
import { seedContacts } from "./contacts";
|
||||
import { seedAssets } from "./assets";
|
||||
import { seedInKind } from "./in-kind";
|
||||
import { seedInstallments } from "./installments";
|
||||
import { seedRecurring } from "./recurring";
|
||||
@@ -51,6 +52,7 @@ export type SeedSummary = {
|
||||
installmentPlans: number;
|
||||
contacts: number;
|
||||
inKind: number;
|
||||
assets: number;
|
||||
};
|
||||
accounts: SeedAccountRow[];
|
||||
};
|
||||
@@ -71,6 +73,8 @@ const DOMAIN_COLLECTIONS = [
|
||||
"contacts",
|
||||
"assetPrices",
|
||||
"inKindDebts",
|
||||
"assets",
|
||||
"assetMovements",
|
||||
];
|
||||
|
||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||
@@ -124,6 +128,7 @@ export async function runSeed({
|
||||
installmentPlans: 0,
|
||||
contacts: 0,
|
||||
inKind: 0,
|
||||
assets: 0,
|
||||
},
|
||||
accounts: [],
|
||||
};
|
||||
@@ -302,6 +307,8 @@ export async function runSeed({
|
||||
if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`);
|
||||
summary.created.inKind = await seedInKind(householdIds, userIdMap);
|
||||
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
|
||||
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 Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
||||
import { getNetWorth } from "@/features/net-worth/service";
|
||||
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
@@ -16,22 +18,27 @@ type SummaryCard = {
|
||||
key: keyof typeof tr.dashboard.cards;
|
||||
tone?: string;
|
||||
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 [
|
||||
{ key: "monthIncome", tone: "text-income", amountMinor: null },
|
||||
{ key: "monthExpense", tone: "text-expense", amountMinor: null },
|
||||
{ key: "monthRemaining", tone: "text-budget", amountMinor: null },
|
||||
{ key: "totalBalance", amountMinor: null },
|
||||
{ key: "cardDebt", tone: "text-expense", amountMinor: null },
|
||||
{ key: "netWorth", amountMinor: null },
|
||||
{ key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" },
|
||||
{ key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null },
|
||||
{ key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" },
|
||||
];
|
||||
}
|
||||
|
||||
async function SummaryCards() {
|
||||
const cards = await getSummary();
|
||||
async function SummaryCards({ userId, householdId }: { userId: string; householdId: string }) {
|
||||
const cards = await getSummary(userId, householdId);
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{cards.map((c) => (
|
||||
@@ -39,7 +46,15 @@ async function SummaryCards() {
|
||||
<CardHeader>
|
||||
<CardDescription>{tr.dashboard.cards[c.key]}</CardDescription>
|
||||
<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>
|
||||
</CardHeader>
|
||||
{c.amountMinor === null ? (
|
||||
@@ -68,7 +83,7 @@ export default async function DashboardPage() {
|
||||
<ContactsDueBanner userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||
<SummaryCards />
|
||||
<SummaryCards userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ export type NavItem = {
|
||||
|
||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||
/** 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;
|
||||
|
||||
@@ -38,7 +38,7 @@ export const MAIN_NAV: NavItem[] = [
|
||||
{ href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 },
|
||||
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.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: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
|
||||
{ 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 () => {
|
||||
expect((await createContact(ayse, cakir, contactForm({ name: "kemal TEST" }))).ok).toBe(
|
||||
false,
|
||||
);
|
||||
expect((await createContact(ayse, cakir, contactForm({ name: "kemal TEST" }))).ok).toBe(false);
|
||||
expect((await setContactArchived(ayse, cakir, ahmet, true)).ok).toBe(true);
|
||||
expect(
|
||||
(await createContactEntry(ayse, cakir, ahmet, entry({ accountId: await acc("Cüzdan") }))).ok,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getAssetOptionsAction, type AssetOption } from "@/features/assets/actions";
|
||||
import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
|
||||
import { AccountSelect } from "@/features/transactions/components/transaction-form";
|
||||
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 });
|
||||
|
||||
/** Hesap seçenekleri (diyaloglar açıldığında). */
|
||||
function useAccounts() {
|
||||
export function useAccounts() {
|
||||
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -79,6 +81,21 @@ function useAccounts() {
|
||||
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 }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
@@ -602,16 +619,23 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
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) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const amount = linked ? "" : v.amount;
|
||||
const r = await recordPaymentAction(item.id, {
|
||||
method,
|
||||
date: v.date,
|
||||
quantity: method === "cash" ? "" : v.quantity,
|
||||
amount: v.amount,
|
||||
accountId: v.amount ? v.accountId || accounts?.[0]?.id || "" : "",
|
||||
amount,
|
||||
accountId: amount ? v.accountId || accounts?.[0]?.id || "" : "",
|
||||
assetId: linked ? linkAssetId : "",
|
||||
note: v.note,
|
||||
});
|
||||
setBusy(false);
|
||||
@@ -659,17 +683,19 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
|
||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field data-invalid={Boolean(errors.amount)}>
|
||||
<FieldLabel htmlFor="pay-amount">
|
||||
{method === "cash" ? f.amount : f.assetAmount}
|
||||
</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pay-amount"
|
||||
value={v.amount}
|
||||
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
||||
/>
|
||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||
</Field>
|
||||
{linked || (method === "asset" && !debt) ? null : (
|
||||
<Field data-invalid={Boolean(errors.amount)}>
|
||||
<FieldLabel htmlFor="pay-amount">
|
||||
{method === "cash" ? f.amount : f.assetAmount}
|
||||
</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pay-amount"
|
||||
value={v.amount}
|
||||
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
||||
/>
|
||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||
</Field>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel>
|
||||
<Input
|
||||
@@ -684,7 +710,49 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
|
||||
{method === "cash" ? (
|
||||
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
|
||||
) : 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)}>
|
||||
<FieldLabel htmlFor="pay-account">{f.account}</FieldLabel>
|
||||
<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 { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { addMovement } from "@/features/assets/service";
|
||||
import { AssetMovementModel } from "@/models/asset-movement";
|
||||
import { priceOn, type PriceQuote } from "./prices";
|
||||
import {
|
||||
ASSET_KEYS,
|
||||
formatAssetQuantity,
|
||||
@@ -38,19 +41,7 @@ const t = tr.inKind;
|
||||
|
||||
// --- Fiyat tablosu ---
|
||||
|
||||
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;
|
||||
}
|
||||
export { priceOn, type PriceQuote } from "./prices";
|
||||
|
||||
export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] };
|
||||
|
||||
@@ -381,6 +372,11 @@ export async function deleteInKind(
|
||||
{ _id: d._id },
|
||||
{ $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({
|
||||
action: "inkind.delete",
|
||||
actorId: actor,
|
||||
@@ -491,7 +487,15 @@ export async function recordPayment(
|
||||
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;
|
||||
if (amountMinor > 0 && account) {
|
||||
const out = d.direction === "debt";
|
||||
@@ -549,10 +553,50 @@ export async function recordPayment(
|
||||
},
|
||||
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 });
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
export async function openInKindByContact(
|
||||
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 {
|
||||
ArrowLeftRight,
|
||||
Coins,
|
||||
Users,
|
||||
CheckCheck,
|
||||
Copy,
|
||||
@@ -130,6 +131,7 @@ export function rowTitle(item: TransactionListItem) {
|
||||
function baseTitle(item: TransactionListItem) {
|
||||
if (item.isAdjustment) return t.adjustment;
|
||||
if (item.contact) return item.contact.name;
|
||||
if (item.asset) return item.asset.name;
|
||||
if (item.payeeName) return item.payeeName;
|
||||
if (item.description) return item.description;
|
||||
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 ?? "—";
|
||||
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")
|
||||
return t.list.transfer(item.account?.name ?? "—", item.toAccount?.name ?? "—");
|
||||
const what = item.isAdjustment
|
||||
@@ -183,7 +189,14 @@ function DetailDialog({
|
||||
onOpenChange={onOpenChange}
|
||||
title={d.title}
|
||||
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)}>
|
||||
<Trash2 />
|
||||
@@ -239,6 +252,14 @@ function DetailDialog({
|
||||
</Link>,
|
||||
)
|
||||
: null}
|
||||
{detail.asset
|
||||
? row(
|
||||
tr.assets.asset,
|
||||
<Link href={`/birikim/${detail.asset.id}`} className="hover:underline">
|
||||
{detail.asset.name}
|
||||
</Link>,
|
||||
)
|
||||
: null}
|
||||
{detail.account
|
||||
? row(
|
||||
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 { toObjectId } from "@/lib/object-id";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AssetModel } from "@/models/asset";
|
||||
import { ContactModel } from "@/models/contact";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { PayeeModel } from "@/models/payee";
|
||||
@@ -70,7 +71,7 @@ export async function loadContext(
|
||||
const m = await resolveMembership(userId, householdId, minRole);
|
||||
if (!m) return null;
|
||||
await postDueInstallments(m.householdId);
|
||||
const [accounts, categories, contacts] = await Promise.all([
|
||||
const [accounts, categories, contacts, assets] = await Promise.all([
|
||||
AccountModel.find({ householdId: m.householdId })
|
||||
.select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 })
|
||||
.lean(),
|
||||
@@ -89,6 +90,9 @@ export async function loadContext(
|
||||
ContactModel.find({ householdId: m.householdId })
|
||||
.select({ name: 1, isPrivate: 1, ownerUserId: 1, archivedAt: 1 })
|
||||
.lean(),
|
||||
AssetModel.find({ householdId: m.householdId })
|
||||
.select({ name: 1, assetKey: 1, isPrivate: 1, ownerUserId: 1, deletedAt: 1 })
|
||||
.lean(),
|
||||
]);
|
||||
const accountMap = new Map<string, AccountInfo>(
|
||||
accounts.map((a) => [
|
||||
@@ -143,6 +147,19 @@ export async function loadContext(
|
||||
]),
|
||||
);
|
||||
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 {
|
||||
m,
|
||||
accountMap,
|
||||
@@ -150,13 +167,15 @@ export async function loadContext(
|
||||
contactMap,
|
||||
hiddenAccountIds,
|
||||
hiddenContactIds,
|
||||
assetMap,
|
||||
hiddenAssetIds,
|
||||
flatCategories: flat,
|
||||
};
|
||||
}
|
||||
|
||||
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> {
|
||||
const f: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null };
|
||||
if (ctx.hiddenAccountIds.length) {
|
||||
@@ -164,6 +183,7 @@ export function baseFilter(ctx: Ctx): Record<string, unknown> {
|
||||
f.toAccountId = { $nin: ctx.hiddenAccountIds };
|
||||
}
|
||||
if (ctx.hiddenContactIds.length) f.contactId = { $nin: ctx.hiddenContactIds };
|
||||
if (ctx.hiddenAssetIds.length) f.assetId = { $nin: ctx.hiddenAssetIds };
|
||||
return f;
|
||||
}
|
||||
|
||||
@@ -473,6 +493,7 @@ export async function updateTransaction(
|
||||
if (current.isAdjustment) return fail(t.adjustmentReadOnly);
|
||||
// Cari hareketi cari sayfasındaki formla düzenlenir (tür ve yön cariye özgü).
|
||||
if (current.contactId) return fail(t.contactEditHint);
|
||||
if (current.assetId) return fail(t.assetLocked);
|
||||
const built = buildDoc(ctx, input, current);
|
||||
if (!built.ok) return built;
|
||||
const d = built.data as {
|
||||
@@ -534,6 +555,8 @@ export async function setTransactionDeleted(
|
||||
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.
|
||||
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);
|
||||
await TransactionModel.updateOne(
|
||||
{ _id: current._id, householdId: ctx.m.householdId },
|
||||
@@ -602,6 +625,8 @@ export type TransactionListItem = {
|
||||
toAccount: { id: string; name: string } | null;
|
||||
/** Cari transferi: kişi ve hareket türü. */
|
||||
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;
|
||||
splitCount: number;
|
||||
payeeName: string;
|
||||
@@ -683,6 +708,9 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
|
||||
kind: tx.contactKind,
|
||||
}
|
||||
: null,
|
||||
asset: tx.assetId
|
||||
? { id: String(tx.assetId), name: ctx.assetMap.get(String(tx.assetId))?.name ?? "—" }
|
||||
: null,
|
||||
installment: tx.installment?.planId
|
||||
? {
|
||||
planId: String(tx.installment.planId),
|
||||
|
||||
+164
@@ -364,6 +364,7 @@ export const tr = {
|
||||
cancelInstallments: "Kalan taksitleri iptal et",
|
||||
contactEditHint: "Cari hareketi cari sayfasından düzenlenir.",
|
||||
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.",
|
||||
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
|
||||
noAccountsLink: "Hesaplara git",
|
||||
@@ -638,6 +639,169 @@ export const tr = {
|
||||
price: "Alış veya satış fiyatı girin",
|
||||
amount: "Tutar girin",
|
||||
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: {
|
||||
|
||||
@@ -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.close",
|
||||
"inkind.delete",
|
||||
"asset.delete",
|
||||
"asset.movement.delete",
|
||||
] as const;
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||
|
||||
|
||||
@@ -64,6 +64,11 @@ const transactionSchema = new Schema(
|
||||
*/
|
||||
inKindDebtId: { type: Schema.Types.ObjectId, 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
|
||||
* 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, contactId: 1, date: 1 }, { sparse: true });
|
||||
transactionSchema.index({ "installment.planId": 1 }, { sparse: true });
|
||||
transactionSchema.index({ householdId: 1, assetId: 1 }, { sparse: true });
|
||||
transactionSchema.index(
|
||||
{ recurringRuleId: 1, occurrenceIndex: 1 },
|
||||
{ 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()),
|
||||
amount: optionalMoney,
|
||||
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(""),
|
||||
})
|
||||
.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)
|
||||
ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity });
|
||||
if (x.method === "cash") {
|
||||
|
||||
Reference in New Issue
Block a user