feat: Faz 9b - dönemsel rapor ve net varlık

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 00:19:11 +03:00
22 changed files with 2030 additions and 60 deletions
+25 -1
View File
@@ -6,6 +6,26 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
## [Yayınlanmamış] ## [Yayınlanmamış]
## [0.9.0] - 2026-11-06
Faz 9: birikim ve varlık takibi, fiyat tablosu (elle), kâr/zarar, net varlık (9a ve 9b alt fazları).
### Eklendi — Faz 9b: dönemsel rapor ve net varlık
- Dönemsel rapor (`/birikim` toplam, `/birikim/:id` varlık bazında): 3 ay / 6 ay / 12 ay / tümü (`?donem=`); dönem başı değer, yatırılan TL, dönem sonu değer, satış geliri, gelen/çıkan hediye ve dönem kâr/zararı (TL ve %, dönemde bağlanan sermayeye oranla). Formül sayfada yazılıdır.
- Aylık değer grafiği (shadcn charts / Recharts): güncel değer (düz çizgi) ve maliyet (kesikli, basamaklı), lejant, ipucu ve tablo görünümü. Fiyatı olmayan varlık toplam rapora katılmaz ve belirtilir.
- Net varlık (`/birikim` üstünde): hesap bakiyeleri + birikim (alış fiyatı) + cari alacaklar + aynî alacaklar (alış) − kart/KMH borçları − cari borçlar − aynî borçlar (satış). Gizli varlık ve cariler yalnızca sahibinin net varlığına girer; gizli hesap bakiyeleri hane ayarına göre.
- Dashboard: "Toplam hesap bakiyesi", "Kart borçları" ve "Net varlık" kartları gerçek veriyle (gelir/gider kartları Faz 14).
- Aynî kayıt ↔ varlık bağlantısı: "Ödeme gerçekleşti → Aynı varlıkla" diyaloğunda isteğe bağlı kutucuk. Borç ödemesi seçilen varlıktan ortalama maliyetle düşer; alacak tahsilatı varlığa borç tarihindeki birim fiyatla eklenir. Ödeme ve varlık hareketi tek transaction'da; varlık yetersizse hiçbir şey yazılmaz. Bağlı hareket Birikim'den tek başına silinemez; aynî kayıt silinince bağ çözülür.
- Fiyat okuma yardımcıları ayrı modülde (`features/in-kind/prices.ts`).
- Testler: dönem noktaları, örnek senaryo ("10 gr'ı 25.000'e aldım, her ay ekledim"), başlangıç değeri, fiyat yokluğu, toplam (birim); dönem raporu, gizli varlık ve fiyatsız varlık, net varlık formülü ve değişmezleri (makas, cari borç), aynî borç/alacak bağlantısı, yetersiz varlıkta geri alma, tür uyuşmazlığı (entegrasyon).
### Değişti — Faz 9b
- "Aynı varlıkla" alacak tahsilatında anlamsız olan "varlığı almak için harcanan" tutar alanı kaldırıldı (yalnızca borç ödemesinde gösterilir).
- `recharts` bağımlılığı ve `components/ui/chart.tsx` eklendi.
- CHANGELOG sürüm karşılaştırma bağlantıları 0.6.0–0.9.0 için tamamlandı.
### Eklendi — Faz 9a: varlık lotları ve kâr/zarar ### 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. - 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.
@@ -264,7 +284,11 @@ Faz 1: Framework, klasör yapısı ve altyapı.
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri. - `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`. - `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.5.0...HEAD [Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.9.0...HEAD
[0.9.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.8.0...v0.9.0
[0.8.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.7.0...v0.8.0
[0.7.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.6.0...v0.7.0
[0.6.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.5.0...v0.6.0
[0.5.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...v0.5.0 [0.5.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...v0.5.0
[0.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0 [0.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0
[0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0 [0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0
+372 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "butcem", "name": "butcem",
"version": "0.8.0", "version": "0.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "butcem", "name": "butcem",
"version": "0.8.0", "version": "0.9.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^7.0.2", "@hookform/resolvers": "^7.0.2",
"@tanstack/react-table": "^9.3.1", "@tanstack/react-table": "^9.3.1",
@@ -24,6 +24,7 @@
"react": "^19.3.1", "react": "^19.3.1",
"react-dom": "^19.3.1", "react-dom": "^19.3.1",
"react-hook-form": "^7.91.0", "react-hook-form": "^7.91.0",
"recharts": "^3.8.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"shadcn": "^4.23.0", "shadcn": "^4.23.0",
"sharp": "^0.35.5", "sharp": "^0.35.5",
@@ -4051,6 +4052,46 @@
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.14.0.tgz",
"integrity": "sha512-m/PxMwRe0xLL6XUsAgy0YjInklHcfi9uMwFhJk+QINqjxqPvyc89T5cMjszA5bDmTE0gwGc7zr9LRO4gL+mhaw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.2.9",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.2.9",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.2.9.tgz",
"integrity": "sha512-IQ4UEtXjZjyefyWjtLEDXUSrF6T2hwjKPi6BIJl3bY/2XXW24d6cNGdXJSBOA/+KNnuiXkZY6DR0C3efolhitA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": { "node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.16", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
@@ -4964,6 +5005,69 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -5022,6 +5126,12 @@
"@types/react": "^19.3.0" "@types/react": "^19.3.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/validate-npm-package-name": { "node_modules/@types/validate-npm-package-name": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
@@ -6573,6 +6683,127 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.5.tgz",
"integrity": "sha512-Th8qGXoBalmWsstpkuhmBuTsBQIFqYOJtjlBvNVvBfJ8E3uYusBUwUR8HU20U0qKKQkgCvKyR1YjiozWadk5QA==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.1.tgz",
"integrity": "sha512-lfab8x0Bj0UO7SNsY8dZSbNIA6ipbkE3X0NG9nqjCu6CfCHZseuDnOSJY7hidKrjpP++i1M2qT+FlAdPKYRilA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-B5znKqRmKROAN4oHWQ50W/qisVjA9GXh2WMsVKdFVp4DxWmcrOsweqgUOUVsv5th96kwaZlokU6SlV4J3gak1w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.3.tgz",
"integrity": "sha512-+F+/49swqmGuQJmSKnVMIDH/QkEFV5li9QRj0xm/KhyAUBv099O5PrvpQFxBp24PYph17Mnvt9mjKQV0ii0nMA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.2.tgz",
"integrity": "sha512-IRzGU62DtxhvsnSTWMQIdnoZ6gA+BCWwBon1EJI6SjoN595799b6z/QRBzXfFhE8iupUPp3042yBNo0STPqnzQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-jsbgV8zIT38ReB9O7X7gcLndTQRONSqNNd+oGIaTBdUug4r5jFmp0N6RY9R5GWbdUDucn0qoTGyfjapT4tDhcg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.3.tgz",
"integrity": "sha512-66cWobCNzcMvlfoaDHCxuIv9TBUWG1BLOWdv9kHHFuCr1JdqAWPvTmsaEYDD5NRMyWZ5+7u0GqiCDfMXMqh3hg==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.1.tgz",
"integrity": "sha512-dm2o+lbKdzg9tY7GEag/dB9hcbodLfwkE/DkvqbELJXaMz0yKzWWhZqhbI7CKubukA7HnRyz0avdtvi7EPxPvw==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.1.tgz",
"integrity": "sha512-Ze7rjrhMzdeulh0/hjcfFj7XLAHOfowtEMfsoG6DU866HPynQNW7Wfen6hzXcqKTv8XzxvF6Hzr5XD6bMHr2ug==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.1.tgz",
"integrity": "sha512-22XaqjyCjpqgda20erhsOjGmC3vvYwTNYniKAd45W5UWLDF43moeOuV9qYpRqobIlsnK0UhnKZjUUJm3VgORUA==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-1Il1aliroVSErrv5aulRBxAY+7+DnaoLIWGd1C6SsBTWDeobWiP/V69vwb8vT6abR1kKwaNe1196pmaQ1fK90A==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -6666,6 +6897,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -7140,6 +7377,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.53.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.1.tgz",
"integrity": "sha512-VhMc6IXmexu8iYFfL2aaAww6IvJTdHlqBLruLoEUs33YD4IcP5DdZcLOybMsMmmLsiy1c8PsiLsrM0DdIuuRUA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types",
"tests/browser-compat"
]
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -7658,6 +7907,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/execa": { "node_modules/execa": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
@@ -8282,6 +8537,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -8324,6 +8589,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.7.6", "version": "10.7.6",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz",
@@ -10715,9 +10989,31 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-redux": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.1.tgz",
"integrity": "sha512-vxMLT0lalUjbZPkoTepWyKAZbjIIFssNx/CEL9lTfwQBU6XpkGSmCNdcCIrkOBUgB68uftZMnbXVb6DwGnhRKQ==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -10803,6 +11099,51 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/recharts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -10856,6 +11197,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "2.0.0-next.7", "version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -12405,6 +12752,28 @@
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
} }
}, },
"node_modules/victory-vendor": {
"version": "37.4.1",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.4.1.tgz",
"integrity": "sha512-geV4p2GVfVM9lgm9ZRS3pvsbAPS+BUGhM9W9nPzPHTKi+/+KjNrFXdgP6KkG+XspNZuvcXmchcrtklfuQ4gniw==",
"license": "MIT AND ISC AND BSD-3-Clause",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.3.5", "version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "butcem", "name": "butcem",
"version": "0.8.0", "version": "0.9.0",
"private": true, "private": true,
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
"engines": { "engines": {
@@ -42,6 +42,7 @@
"react": "^19.3.1", "react": "^19.3.1",
"react-dom": "^19.3.1", "react-dom": "^19.3.1",
"react-hook-form": "^7.91.0", "react-hook-form": "^7.91.0",
"recharts": "^3.8.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"shadcn": "^4.23.0", "shadcn": "^4.23.0",
"sharp": "^0.35.5", "sharp": "^0.35.5",
+25 -6
View File
@@ -1,17 +1,36 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { AssetDetailView } from "@/features/assets/components/asset-detail-view"; import { AssetDetailView } from "@/features/assets/components/asset-detail-view";
import { getAsset } from "@/features/assets/service"; 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 { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal"; import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.assets.history }; export const metadata: Metadata = { title: tr.assets.history };
/** /birikim/:id — varlık hareketleri (gizli varlık yalnızca sahibine; aksi 404). */ /** /birikim/:id — varlık hareketleri ve dönemsel rapor (gizli varlık yalnızca sahibine; aksi 404). */
export default async function AssetPage({ params }: PageProps<"/birikim/[id]">) { export default async function AssetPage({ params, searchParams }: PageProps<"/birikim/[id]">) {
const { user, household } = await requireActiveHousehold("viewer"); const { user, household } = await requireActiveHousehold("viewer");
const { id } = await params; const { id } = await params;
const data = await getAsset(user.id, household.id, id); const donem = (await searchParams).donem;
if (!data) notFound(); const range: ReportRange = REPORT_RANGES.includes(donem as ReportRange)
return <AssetDetailView key={id} data={data} />; ? (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}`} />}
/>
);
} }
+29 -5
View File
@@ -2,21 +2,45 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { AssetsView } from "@/features/assets/components/assets-view"; import { AssetsView } from "@/features/assets/components/assets-view";
import { listAssets } from "@/features/assets/service"; 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 { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal"; import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.assets.title }; export const metadata: Metadata = { title: tr.assets.title };
/** /birikim — altın, döviz ve ziynet varlıkları; ortalama maliyet ve kâr/zarar. */ function parseRange(value: string | string[] | undefined): ReportRange {
export default async function AssetsPage() { 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 { user, household } = await requireActiveHousehold("viewer");
const data = await listAssets(user.id, household.id); const range = parseRange((await searchParams).donem);
if (!data) notFound(); 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 ( return (
<> <>
<PageHeader title={tr.assets.title} description={tr.assets.description} /> <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} /> <AssetsView items={data.items} totals={data.totals} canWrite={data.canWrite} />
<PeriodReportView report={report} range={range} basePath="/birikim" />
</> </>
); );
} }
+24 -9
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
import { ContactsDueBanner } from "@/features/contacts/components/due-banner"; import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
import { getNetWorth } from "@/features/net-worth/service";
import { OverdueBanner } from "@/features/recurring/components/overdue-banner"; import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal"; import { requireActiveHousehold } from "@/lib/dal";
@@ -16,22 +18,27 @@ type SummaryCard = {
key: keyof typeof tr.dashboard.cards; key: keyof typeof tr.dashboard.cards;
tone?: string; tone?: string;
amountMinor: number | null; amountMinor: number | null;
href?: string;
}; };
/** FAZ 14: gerçek özet verisi (gelir, gider, bakiye, kart borcu, net varlık). */ /**
async function getSummary(): Promise<SummaryCard[]> { * Özet kartları. Bakiye, kart borcu ve net varlık gerçek (Faz 9); bu ayın gelir/gider/kalan
* kartları Faz 14'te dolar.
*/
async function getSummary(userId: string, householdId: string): Promise<SummaryCard[]> {
const nw = await getNetWorth(userId, householdId);
return [ return [
{ key: "monthIncome", tone: "text-income", amountMinor: null }, { key: "monthIncome", tone: "text-income", amountMinor: null },
{ key: "monthExpense", tone: "text-expense", amountMinor: null }, { key: "monthExpense", tone: "text-expense", amountMinor: null },
{ key: "monthRemaining", tone: "text-budget", amountMinor: null }, { key: "monthRemaining", tone: "text-budget", amountMinor: null },
{ key: "totalBalance", amountMinor: null }, { key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" },
{ key: "cardDebt", tone: "text-expense", amountMinor: null }, { key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null },
{ key: "netWorth", amountMinor: null }, { key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" },
]; ];
} }
async function SummaryCards() { async function SummaryCards({ userId, householdId }: { userId: string; householdId: string }) {
const cards = await getSummary(); const cards = await getSummary(userId, householdId);
return ( return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
{cards.map((c) => ( {cards.map((c) => (
@@ -39,7 +46,15 @@ async function SummaryCards() {
<CardHeader> <CardHeader>
<CardDescription>{tr.dashboard.cards[c.key]}</CardDescription> <CardDescription>{tr.dashboard.cards[c.key]}</CardDescription>
<CardTitle className={cn("text-lg tabular-nums", c.tone)}> <CardTitle className={cn("text-lg tabular-nums", c.tone)}>
{c.amountMinor === null ? "—" : formatMoney(c.amountMinor)} {c.amountMinor === null ? (
"—"
) : c.href ? (
<Link href={c.href} className="hover:underline">
{formatMoney(c.amountMinor)}
</Link>
) : (
formatMoney(c.amountMinor)
)}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
{c.amountMinor === null ? ( {c.amountMinor === null ? (
@@ -68,7 +83,7 @@ export default async function DashboardPage() {
<ContactsDueBanner userId={user.id} householdId={household.id} /> <ContactsDueBanner userId={user.id} householdId={household.id} />
</Suspense> </Suspense>
<Suspense fallback={<SummaryCardsSkeleton />}> <Suspense fallback={<SummaryCardsSkeleton />}>
<SummaryCards /> <SummaryCards userId={user.id} householdId={household.id} />
</Suspense> </Suspense>
</> </>
); );
+1 -1
View File
@@ -27,7 +27,7 @@ export type NavItem = {
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
export const CURRENT_PHASE = 9.1; export const CURRENT_PHASE = 9.2;
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
+338
View File
@@ -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,
};
+27 -2
View File
@@ -3,10 +3,17 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result"; import { fail, ok, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal"; import { getHouseholdActionContext } from "@/lib/dal";
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset"; import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
import { addMovement, createAsset, deleteAsset, deleteMovement, updateAsset } from "./service"; import {
addMovement,
createAsset,
deleteAsset,
deleteMovement,
listAssets,
updateAsset,
} from "./service";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
@@ -60,3 +67,21 @@ export async function deleteMovementAction(movementId: unknown) {
if (!id.success) return fail(tr.common.notFound); if (!id.success) return fail(tr.common.notFound);
return run(z.unknown(), null, (u, h) => deleteMovement(u, h, id.data)); 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,
})),
);
}
@@ -54,7 +54,14 @@ type Pending =
| null; | null;
/** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */ /** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */
export function AssetDetailView({ data }: { data: AssetDetail }) { export function AssetDetailView({
data,
report,
}: {
data: AssetDetail;
/** Dönemsel rapor (sunucuda hazırlanır). */
report?: React.ReactNode;
}) {
const router = useRouter(); const router = useRouter();
const [pending, setPending] = useState<Pending>(null); const [pending, setPending] = useState<Pending>(null);
const close = () => setPending(null); const close = () => setPending(null);
@@ -171,6 +178,8 @@ export function AssetDetailView({ data }: { data: AssetDetail }) {
</CardContent> </CardContent>
</Card> </Card>
{report}
<section className="flex flex-col gap-2" aria-labelledby="movements-title"> <section className="flex flex-col gap-2" aria-labelledby="movements-title">
<h2 id="movements-title" className="text-base font-medium"> <h2 id="movements-title" className="text-base font-medium">
{t.history} {t.history}
@@ -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>
);
}
+60 -1
View File
@@ -1,7 +1,7 @@
import "server-only"; import "server-only";
import mongoose, { Types } from "mongoose"; import mongoose, { Types } from "mongoose";
import { hasHouseholdRole } from "@/features/households/roles"; import { hasHouseholdRole } from "@/features/households/roles";
import { priceOn, type PriceQuote } from "@/features/in-kind/service"; import { buyPriceLookup, priceOn, type PriceQuote } from "@/features/in-kind/prices";
import { loadContext, type Ctx } from "@/features/transactions/service"; import { loadContext, type Ctx } from "@/features/transactions/service";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result"; import { fail, ok, type ActionResult } from "@/lib/action-result";
@@ -14,10 +14,14 @@ import { formatAssetQuantity, valueOf, type AssetKey } from "@/shared/assets";
import { istanbulDayStart, todayIstanbul } from "@/shared/dates"; import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
import { import {
holdingSummary, holdingSummary,
periodReport,
runMovements, runMovements,
sumReports,
type HoldingSummary, type HoldingSummary,
type MovementKind, type MovementKind,
type PeriodReport,
} from "@/shared/portfolio"; } from "@/shared/portfolio";
import { addDays, addMonths } from "@/shared/recurrence";
import { QUANTITY_SCALE } from "@/shared/quantity"; import { QUANTITY_SCALE } from "@/shared/quantity";
import type { AssetForm, MovementForm } from "@/shared/schemas/asset"; import type { AssetForm, MovementForm } from "@/shared/schemas/asset";
import { compareTr, foldTr } from "@/shared/search"; import { compareTr, foldTr } from "@/shared/search";
@@ -482,3 +486,58 @@ export async function deleteMovement(
}); });
return ok(undefined); 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 };
}
@@ -16,6 +16,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
@@ -35,6 +36,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getAssetOptionsAction, type AssetOption } from "@/features/assets/actions";
import { getTransactionFormOptionsAction } from "@/features/transactions/actions"; import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
import { AccountSelect } from "@/features/transactions/components/transaction-form"; import { AccountSelect } from "@/features/transactions/components/transaction-form";
import type { TransactionFormOptions } from "@/features/transactions/service"; import type { TransactionFormOptions } from "@/features/transactions/service";
@@ -79,6 +81,21 @@ export function useAccounts() {
return options?.accounts ?? null; return options?.accounts ?? null;
} }
/** Kaydın varlık türündeki birikim varlıkları (ödeme diyaloğu açıldığında). */
function useAssetOptions(assetKey: AssetKey) {
const [options, setOptions] = useState<AssetOption[] | null>(null);
useEffect(() => {
let cancelled = false;
getAssetOptionsAction().then((r) => {
if (!cancelled && r.ok) setOptions(r.data.filter((a) => a.assetKey === assetKey));
});
return () => {
cancelled = true;
};
}, [assetKey]);
return options;
}
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) { function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
return ( return (
<div className="min-w-0"> <div className="min-w-0">
@@ -602,16 +619,23 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
}); });
const [errors, setErrors] = useState<Errors>({}); const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const assets = useAssetOptions(item.assetKey);
const [link, setLink] = useState(false);
const [assetId, setAssetId] = useState("");
const linked = method === "asset" && link;
const linkAssetId = assetId || assets?.[0]?.id || "";
async function submit(e: React.FormEvent) { async function submit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setBusy(true); setBusy(true);
const amount = linked ? "" : v.amount;
const r = await recordPaymentAction(item.id, { const r = await recordPaymentAction(item.id, {
method, method,
date: v.date, date: v.date,
quantity: method === "cash" ? "" : v.quantity, quantity: method === "cash" ? "" : v.quantity,
amount: v.amount, amount,
accountId: v.amount ? v.accountId || accounts?.[0]?.id || "" : "", accountId: amount ? v.accountId || accounts?.[0]?.id || "" : "",
assetId: linked ? linkAssetId : "",
note: v.note, note: v.note,
}); });
setBusy(false); setBusy(false);
@@ -659,17 +683,19 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null} {errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
</Field> </Field>
) : null} ) : null}
<Field data-invalid={Boolean(errors.amount)}> {linked || (method === "asset" && !debt) ? null : (
<FieldLabel htmlFor="pay-amount"> <Field data-invalid={Boolean(errors.amount)}>
{method === "cash" ? f.amount : f.assetAmount} <FieldLabel htmlFor="pay-amount">
</FieldLabel> {method === "cash" ? f.amount : f.assetAmount}
<MoneyInput </FieldLabel>
id="pay-amount" <MoneyInput
value={v.amount} id="pay-amount"
onChange={(x) => setV((s) => ({ ...s, amount: x }))} value={v.amount}
/> onChange={(x) => setV((s) => ({ ...s, amount: x }))}
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null} />
</Field> {errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
</Field>
)}
<Field> <Field>
<FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel> <FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel>
<Input <Input
@@ -684,7 +710,49 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
{method === "cash" ? ( {method === "cash" ? (
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription> <FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
) : null} ) : null}
{method === "cash" || v.amount ? ( {method === "asset" ? (
<div className="flex flex-col gap-2 rounded-lg border p-3">
<label className="flex items-start gap-2 text-sm">
<Checkbox
checked={link}
onCheckedChange={(x) => setLink(x === true)}
disabled={!assets?.length}
className="mt-0.5"
/>
<span>
{debt ? t.link.debt : t.link.receivable}
<span className="block text-xs text-muted-foreground">
{assets && !assets.length
? t.link.none
: debt
? t.link.debtHint
: t.link.receivableHint}
</span>
</span>
</label>
{link && assets?.length ? (
<Field data-invalid={Boolean(errors.assetId)}>
<FieldLabel htmlFor="pay-asset" className="sr-only">
{t.link.asset}
</FieldLabel>
<Select value={linkAssetId} onValueChange={setAssetId}>
<SelectTrigger id="pay-asset" className="w-full">
<SelectValue>{assets.find((a) => a.id === linkAssetId)?.name}</SelectValue>
</SelectTrigger>
<SelectContent>
{assets.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name} · {formatAssetQuantity(item.assetKey, a.quantity)}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.assetId ? <FieldError>{errors.assetId}</FieldError> : null}
</Field>
) : null}
</div>
) : null}
{(method === "cash" || v.amount) && !linked ? (
<Field data-invalid={Boolean(errors.accountId)}> <Field data-invalid={Boolean(errors.accountId)}>
<FieldLabel htmlFor="pay-account">{f.account}</FieldLabel> <FieldLabel htmlFor="pay-account">{f.account}</FieldLabel>
<AccountSelect <AccountSelect
+60
View File
@@ -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;
};
}
+59 -15
View File
@@ -9,6 +9,9 @@ import { toObjectId } from "@/lib/object-id";
import { AssetPriceModel } from "@/models/asset-price"; import { AssetPriceModel } from "@/models/asset-price";
import { InKindDebtModel } from "@/models/in-kind-debt"; import { InKindDebtModel } from "@/models/in-kind-debt";
import { TransactionModel } from "@/models/transaction"; import { TransactionModel } from "@/models/transaction";
import { addMovement } from "@/features/assets/service";
import { AssetMovementModel } from "@/models/asset-movement";
import { priceOn, type PriceQuote } from "./prices";
import { import {
ASSET_KEYS, ASSET_KEYS,
formatAssetQuantity, formatAssetQuantity,
@@ -38,19 +41,7 @@ const t = tr.inKind;
// --- Fiyat tablosu --- // --- Fiyat tablosu ---
export type PriceQuote = { buyMinor: number; sellMinor: number; date: string }; export { priceOn, type PriceQuote } from "./prices";
/** Varlığın verilen tarihteki (veya öncesindeki en yakın) fiyatı. */
export async function priceOn(
householdId: Types.ObjectId,
assetKey: AssetKey,
date: string,
): Promise<PriceQuote | null> {
const p = await AssetPriceModel.findOne({ householdId, assetKey, date: { $lte: date } })
.sort({ date: -1 })
.lean();
return p ? { buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date } : null;
}
export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] }; export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] };
@@ -381,6 +372,11 @@ export async function deleteInKind(
{ _id: d._id }, { _id: d._id },
{ $set: { deletedAt: new Date(), updatedBy: actor } }, { $set: { deletedAt: new Date(), updatedBy: actor } },
); );
// Bağlı varlık hareketleri serbest kalır (Birikim sayfasından yönetilebilir).
await AssetMovementModel.updateMany(
{ householdId: ctx.m.householdId, inKindDebtId: d._id },
{ $set: { inKindDebtId: null } },
);
await writeAudit({ await writeAudit({
action: "inkind.delete", action: "inkind.delete",
actorId: actor, actorId: actor,
@@ -491,7 +487,15 @@ export async function recordPayment(
contactName, contactName,
); );
await mongoose.connection.transaction(async (session) => { if (input.assetId) {
const asset = ctx.assetMap.get(input.assetId);
if (!asset || asset.hidden || asset.deleted)
return fail(tr.common.notFound, { assetId: tr.common.notFound });
if (asset.assetKey !== d.assetKey)
return fail(t.link.assetMismatch, { assetId: t.link.assetMismatch });
}
const writePayment = async (session: mongoose.ClientSession) => {
let transactionId: Types.ObjectId | null = null; let transactionId: Types.ObjectId | null = null;
if (amountMinor > 0 && account) { if (amountMinor > 0 && account) {
const out = d.direction === "debt"; const out = d.direction === "debt";
@@ -549,10 +553,50 @@ export async function recordPayment(
}, },
session, session,
); );
}); };
try {
await mongoose.connection.transaction(async (session) => {
// Varlık bağlantısı: borç ödemesi varlıktan çıkış, alacak tahsilatı varlığa giriş.
if (input.assetId) {
const r = await addMovement(
actorId,
householdId,
input.assetId,
{
kind: d.direction === "debt" ? "gift_out" : "gift_in",
date: input.date,
quantity,
unitPrice: d.direction === "debt" ? null : (d.unitPriceMinor ?? null),
amount: null,
accountId: null,
storage: "",
note: t.link.movementNote(contactName),
},
{ inKindDebtId: d._id, session },
);
if (!r.ok) throw new LinkError(r.error, r.fieldErrors?.quantity);
}
await writePayment(session);
});
} catch (error) {
if (error instanceof LinkError)
return fail(error.message, error.quantity ? { quantity: error.quantity } : undefined);
throw error;
}
return ok({ closed }); return ok({ closed });
} }
/** Varlık hareketi eklenemedi: transaction geri alınır, hata kullanıcıya döner. */
class LinkError extends Error {
constructor(
message: string,
readonly quantity?: string,
) {
super(message);
}
}
/** Cari listesi için açık aynî kayıt özetleri (cari kimliği → etiketler). */ /** Cari listesi için açık aynî kayıt özetleri (cari kimliği → etiketler). */
export async function openInKindByContact( export async function openInKindByContact(
userId: string, userId: string,
@@ -0,0 +1,62 @@
import { Card, CardContent } from "@/components/ui/card";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format";
import type { NetWorth } from "../service";
const t = tr.assets.netWorth;
/** Net varlık dökümü: artılar ve eksiler, altta toplam. */
export function NetWorthCard({ data }: { data: NetWorth }) {
const rows: { label: string; minor: number; sign: 1 | -1 }[] = [
{ label: t.accounts, minor: data.accountsMinor, sign: 1 },
{ label: t.assets, minor: data.assetsMinor, sign: 1 },
{ label: t.contactReceivable, minor: data.contactReceivableMinor, sign: 1 },
{ label: t.inKindReceivable, minor: data.inKindReceivableMinor, sign: 1 },
{ label: t.cardDebt, minor: data.cardDebtMinor, sign: -1 },
{ label: t.contactPayable, minor: data.contactPayableMinor, sign: -1 },
{ label: t.inKindPayable, minor: data.inKindPayableMinor, sign: -1 },
];
const plus = rows.filter((r) => r.sign === 1);
const minus = rows.filter((r) => r.sign === -1);
const list = (items: typeof rows) => (
<dl className="flex flex-col text-sm">
{items.map((r) => (
<div key={r.label} className="flex items-center justify-between gap-2 py-1">
<dt className="text-muted-foreground">{r.label}</dt>
<dd className="tabular-nums">
{r.sign < 0 && r.minor ? "−" : ""}
{formatMoney(r.minor)}
</dd>
</div>
))}
</dl>
);
return (
<Card size="sm">
<CardContent className="flex flex-col gap-3">
<div>
<p
className={cn(
"text-3xl font-semibold tracking-tight tabular-nums",
data.totalMinor < 0 && "text-expense",
)}
>
{formatMoney(data.totalMinor)}
</p>
<p className="text-sm text-muted-foreground">{t.description}</p>
</div>
<div className="grid gap-x-8 gap-y-1 border-t pt-2 sm:grid-cols-2">
{list(plus)}
{list(minus)}
</div>
{data.missingPrice ? (
<p className="text-xs text-muted-foreground">{t.missingPrice(data.missingPrice)}</p>
) : null}
{data.includesHidden ? (
<p className="text-xs text-muted-foreground">{t.includesHidden}</p>
) : null}
</CardContent>
</Card>
);
}
+310
View File
@@ -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 });
});
});
+71
View File
@@ -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,
};
}
+50
View File
@@ -639,6 +639,19 @@ export const tr = {
price: "Alış veya satış fiyatı girin", price: "Alış veya satış fiyatı girin",
amount: "Tutar girin", amount: "Tutar girin",
account: "Hesap seçin", account: "Hesap seçin",
assetOnlyWithAsset: "Varlık bağlantısı yalnızca aynı varlıkla ödemede kullanılır.",
noAmountWithAsset: "Varlıktan ödemede TL tutar girilmez.",
},
link: {
debt: "Birikimdeki varlıktan düş",
receivable: "Birikimdeki varlığa ekle",
debtHint: "Ödenen miktar seçilen varlıktan ortalama maliyetle çıkar.",
receivableHint:
"Tahsil edilen miktar seçilen varlığa eklenir; maliyet borç tarihindeki birim fiyattır.",
asset: "Varlık",
none: "Bu türde varlık yok; önce Birikim sayfasından ekleyin.",
movementNote: (name: string) => `Aynî kayıt: ${name}`,
assetMismatch: "Seçilen varlık bu kaydın varlık türünde değil.",
}, },
}, },
assets: { assets: {
@@ -713,6 +726,43 @@ export const tr = {
}, },
history: "Hareketler", history: "Hareketler",
noMovements: "Henüz hareket yok.", 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: { table: {
date: "Tarih", date: "Tarih",
kind: "Hareket", kind: "Hareket",
+74 -1
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { formatBp, holdingSummary, runMovements, type MovementInput } from "./portfolio"; import {
formatBp,
holdingSummary,
periodPoints,
periodReport,
runMovements,
sumReports,
type DatedMovement,
type MovementInput,
} from "./portfolio";
const buy = (quantity: number, amountMinor: number): MovementInput => ({ const buy = (quantity: number, amountMinor: number): MovementInput => ({
kind: "buy", kind: "buy",
@@ -70,3 +79,67 @@ describe("ağırlıklı ortalama maliyet", () => {
expect(formatBp(0)).toBe("%0"); 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);
});
});
+139
View File
@@ -1,4 +1,5 @@
import { QUANTITY_SCALE } from "./quantity"; 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 * Varlık takibi hesap motoru (Bölüm 6.6): ağırlıklı ortalama maliyet, gerçekleşmiş ve
@@ -144,3 +145,141 @@ export function formatBp(bp: number): string {
const s = (Math.abs(bp) / 100).toLocaleString("tr-TR", { maximumFractionDigits: 2 }); const s = (Math.abs(bp) / 100).toLocaleString("tr-TR", { maximumFractionDigits: 2 });
return `${bp < 0 ? "−" : bp > 0 ? "+" : ""}%${s}`; 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,
};
}
+6
View File
@@ -101,9 +101,15 @@ export const paymentFormSchema = z
quantity: z.preprocess(emptyToNull, quantity.nullable()), quantity: z.preprocess(emptyToNull, quantity.nullable()),
amount: optionalMoney, amount: optionalMoney,
accountId: z.preprocess(emptyToNull, objectId.nullable()), accountId: z.preprocess(emptyToNull, objectId.nullable()),
/** Aynı varlıkla ödemede bağlanacak varlık (Faz 9b): borçta varlıktan düşer, alacakta eklenir. */
assetId: z.preprocess(emptyToNull, objectId.nullable()).default(null),
note: z.string().trim().max(300).default(""), note: z.string().trim().max(300).default(""),
}) })
.superRefine((x, ctx) => { .superRefine((x, ctx) => {
if (x.assetId && x.method !== "asset")
ctx.addIssue({ code: "custom", path: ["assetId"], message: k.assetOnlyWithAsset });
if (x.assetId && x.amount)
ctx.addIssue({ code: "custom", path: ["amount"], message: k.noAmountWithAsset });
if (x.method === "asset" && x.quantity === null) if (x.method === "asset" && x.quantity === null)
ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity }); ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity });
if (x.method === "cash") { if (x.method === "cash") {