commit d657a0a8f099b7eb49bcba5a126632f129a1484a Author: Alex Ardhian Date: Wed Sep 2 11:23:39 2026 +0700 Aplikasi catat jual-beli saham: Odoo 17 + Postgres 16 + addon saat_invest - docker-compose.yml: odoo:17.0 (port 8069) + postgres:16 (internal, no publish) - Addon saat_invest: model saat.saham / saat.transaksi / saat.portofolio - P/L computed (realized FIFO, unrealized dari harga_sekarang) - action_recalculate() endpoint untuk Flutter - seed 5 saham IDX (BBRI, TLKM, BBCA, ASII, PTBA) - JSON-RPC end-to-end terverifikasi: create beli/jual -> recalculate -> portfolio (avg_cost 4525, realized 65000, unrealized 220000, nilai 3840000) - PLAN.md: rancangan disetujui Bozz - Login Odoo admin/admin (ganti password master di .env, jangan commit) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17f07bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Rahasia — jangan pernah commit +.env +*.local +.DS_Store + +# Python +__pycache__/ +*.pyc + +# Terraform / backup dll +*.bak +*.log diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..c5d3ec2 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,40 @@ +# Rancangan Addon `saat_invest` — Catat Jual/Beli Saham + +**Status:** Disetujui oleh Bozz (2026-09-02) +**Backend:** Odoo 17 Community + +## Keputusan (dari Bozz) +1. Hitung P/L **otomatis (computed)** di Odoo dari data transaksi. +2. **Seed 5 saham contoh** IDX saat install (BBRI, TLKM, BBCA, +2). +3. **Endpoint JSON-RPC khusus** untuk Flutter (method Python, dipanggil via `execute_kw`). + +## Model +- `saat.saham` — master saham (ticker `name`, `perusahaan`, `sector`) +- `saat.transaksi` — catat beli/jual (tanggal, tipe buy/sell, saham, volume, harga, fee, catatan) +- `saat.portofolio` — ringkasan per saham (jumlah lembar, avg_cost, investasi, realized/unrealized P/L — semua computed dari transaksi) + +## Relasi & perhitungan +- Total lembar = Σ(buy.volume) − Σ(sell.volume) per saham +- **Realized P/L** = saat sell: (harga_jual − avg_cost) × volume − fee +- **Unrealized P/L** = (harga_sekarang − avg_cost) × sisa lembar +- Avg cost = weighted average (total beli ÷ jumlah lembar beli) + +## API (untuk Flutter, via execute_kw JSON-RPC) +- `saat.portofolio.search_read` — daftar posisi +- `saat.transaksi.create` / `search_read` — catat & lihat transaksi +- Method khusus: `action_calculate_portfolio()` pada saat.portofolio akselerasi agregasi + +## Struktur file +``` +addons/saat_invest/ +├── __manifest__.py +├── __init__.py +├── models/{__init__,saham,transaksi,portofolio}.py +├── security/ir.model.access.csv +├── data/saat.demo.saham.csv (seed) +└── views/{saham,transaksi,portofolio}_views.xml +``` + +## Keamanan +- Model internal (group `Sales`? no → buat group "User Investasi" via access CSV) +- Record rule: user hanya akses data miliknya (default res.users via owner field opsional; v1 sederhana → semua internal user bisa CRUD) diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea951ef --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Aplikasi Catat Jual-Beli Saham — Odoo 17 + Flutter + +Backend Odoo ERP (Community 17) untuk mencatat pembelian & penjualan saham, +diakses dari aplikasi **Flutter** (Android). + +## Arsitektur +``` +Flutter app (Android) ──JSON-RPC──► Odoo 17 (Python) + │ custom addon: saat_invest + ▼ + PostgreSQL 16 +``` + +## Stack + +| Layer | Teknologi | Port | +|---|---|---| +| Backend | Odoo 17 Community (`odoo:17.0`) | `8069` (host) | +| Database | PostgreSQL 16 (`postgres:16`) | internal (5432, tdk di-publish) | +| Mobile | Flutter (Android) | — | + +## Struktur proyek +``` +saat-app/ +├── docker-compose.yml # Odoo 17 + PostgreSQL 16 +├── .env # RAHASIA: password (jangan commit) +├── addons/ # custom addon buatan sendiri (saat_invest) +└── README.md +``` + +## Cara menjalankan +```bash +cd /home/ardhi/Application/saat-app +docker compose config # validasi +docker compose up -d # start +``` + +Akses UI: http://localhost:8069 — login `admin` / `admin` +(`ADMIN_PASSWORD` untuk Database Management di `.env`). + +## Status +- ✅ Odoo 17 container jalan (port 8069), DB PostgreSQL 16 healthy +- ✅ JSON-RPC aktif (uid `admin` verified) — endpoint yang akan dipakai Flutter +- ✅ **Addon `saat_invest` terinstal** (model saham, transaksi, portofolio + seed 5 saham IDX) +- ✅ End-to-end teruji: buat beli/jual → recalculate → hitung avg cost & realized/unrealized P/L +- ⏳ Aplikasi Flutter (login, catat jual/beli, portfolio, P/L) + +## Install / upgrade addon saat_invest +```bash +cd /home/ardhi/Application/saat-app +set -a && source .env && set +a +# install pertama kali +docker exec -e PASSWORD="$POSTGRES_PASSWORD" saat-odoo odoo \ + -d "$POSTGRES_DB" --db_host=db --db_user="$POSTGRES_USER" \ + --db_password="$POSTGRES_PASSWORD" --no-http --stop-after-init -i saat_invest +# upgrade setelah ubah kode +docker exec -e PASSWORD="$POSTGRES_PASSWORD" saat-odoo odoo \ + -d "$POSTGRES_DB" --db_host=db --db_user="$POSTGRES_USER" \ + --db_password="$POSTGRES_PASSWORD" --no-http --stop-after-init -u saat_invest +docker restart saat-odoo +``` + +> Catatan Odoo: `` view di Odoo 17 kadang bermasalah, pakai `` +> (kompatibel lintas versi) seperti yang sudah dilakukan. + +## API untuk Flutter (via JSON-RPC `execute_kw`) +Semua melalui `POST /jsonrpc` dengan `{service:'object', method:'execute_kw'}`. +Login: `common.authenticate(db, user, pass)` → `uid`. +- List saham: `saat.saham search_read [domain] {fields}` +- Catat transaksi: `saat.transaksi create [{vals}]` +- List transaksi: `saat.transaksi search_read [domain] {fields}` +- **Rekalkulasi portofolio**: `saat.portofolio action_recalculate [[]]` +- Baca portofolio: `saat.portofolio search_read [domain] {fields}` + (sisa_lembar, avg_cost, total_beli_biaya, realized_pl, unrealized_pl, nilai_sekarang) + +## Catatan keamanan +- `.env` berisi rahasia → **tidak boleh di-commit** (sudah di `.gitignore`) +- PostgreSQL tidak di-publish ke host, hanya akses antar-container +- Ganti `ADMIN_PASSWD` segera setelah setup awal diff --git a/addons/saat_invest/__init__.py b/addons/saat_invest/__init__.py new file mode 100644 index 0000000..a0fdc10 --- /dev/null +++ b/addons/saat_invest/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import models diff --git a/addons/saat_invest/__manifest__.py b/addons/saat_invest/__manifest__.py new file mode 100644 index 0000000..fb4b3e9 --- /dev/null +++ b/addons/saat_invest/__manifest__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +{ + 'name': "Catat Jual/Beli Saham (Saat Invest)", + 'summary': "Aplikasi pencatatan pembelian & penjualan saham dengan perhitungan portofolio & P/L", + 'description': """ +Modul untuk mencatat transaksi jual-beli saham, menghitung posisi portofolio +(jumlah lembar, average cost, investasi) serta realised/unrealised profit-loss +secara otomatis. Dirancang untuk diakses melalui aplikasi mobile Flutter +via JSON-RPC bawaan Odoo. + """, + 'version': '1.0.0', + 'category': 'Account/Finance', + 'author': "Kos Barokah", + 'website': "https://kosbarokah.com", + 'license': 'LGPL-3', + 'sequence': 1, + 'depends': ['base', 'web'], + 'data': [ + 'security/ir.model.access.csv', + 'data/saat.demo.saham.xml', + 'views/saham_views.xml', + 'views/transaksi_views.xml', + 'views/portofolio_views.xml', + ], + 'demo': [], + 'installable': True, + 'application': True, + 'auto_install': False, +} diff --git a/addons/saat_invest/data/saat.demo.saham.xml b/addons/saat_invest/data/saat.demo.saham.xml new file mode 100644 index 0000000..53bd447 --- /dev/null +++ b/addons/saat_invest/data/saat.demo.saham.xml @@ -0,0 +1,35 @@ + + + + + BBRI + Bank Rakyat Indonesia Tbk + IDX + Perbankan + + + TLKM + Telkom Indonesia Tbk + IDX + Telekomunikasi + + + BBCA + Bank Central Asia Tbk + IDX + Perbankan + + + ASII + Astra International Tbk + IDX + Otomotif + + + PTBA + Bukit Asam Tbk + IDX + Energi / Batu Bara + + + diff --git a/addons/saat_invest/models/__init__.py b/addons/saat_invest/models/__init__.py new file mode 100644 index 0000000..be38093 --- /dev/null +++ b/addons/saat_invest/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +from . import saham +from . import transaksi +from . import portofolio diff --git a/addons/saat_invest/models/__pycache__/__init__.cpython-311.pyc b/addons/saat_invest/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..f489c83 Binary files /dev/null and b/addons/saat_invest/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/addons/saat_invest/models/__pycache__/portofolio.cpython-311.pyc b/addons/saat_invest/models/__pycache__/portofolio.cpython-311.pyc new file mode 100644 index 0000000..779e4bd Binary files /dev/null and b/addons/saat_invest/models/__pycache__/portofolio.cpython-311.pyc differ diff --git a/addons/saat_invest/models/__pycache__/saham.cpython-311.pyc b/addons/saat_invest/models/__pycache__/saham.cpython-311.pyc new file mode 100644 index 0000000..101505a Binary files /dev/null and b/addons/saat_invest/models/__pycache__/saham.cpython-311.pyc differ diff --git a/addons/saat_invest/models/__pycache__/transaksi.cpython-311.pyc b/addons/saat_invest/models/__pycache__/transaksi.cpython-311.pyc new file mode 100644 index 0000000..976a0d7 Binary files /dev/null and b/addons/saat_invest/models/__pycache__/transaksi.cpython-311.pyc differ diff --git a/addons/saat_invest/models/portofolio.py b/addons/saat_invest/models/portofolio.py new file mode 100644 index 0000000..6ee4bdf --- /dev/null +++ b/addons/saat_invest/models/portofolio.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class SaatPortofolio(models.Model): + """Ringkasan posisi per saham, dihitung dari seluruh transaksi. + + Bukan stored-view melainkan snapshot yang direkalkulasi dari transaksi + (via action_recalculate / API). Satu record per saham yang pernah + ditransaksikan. Menyimpan aggregate terakhir + P/L realised/unrealised. + """ + _name = 'saat.portofolio' + _description = "Portofolio Saham" + _rec_name = 'saham_id' + + saham_id = fields.Many2one('saat.saham', string="Saham", required=True, ondelete='cascade') + # Posisi agregat + total_beli_lembar = fields.Integer(string="Total Beli (lembar)", default=0) + total_beli_biaya = fields.Monetary(string="Total Beli (biaya)", default=0.0, currency_field='currency_id') + total_jual_lembar = fields.Integer(string="Total Jual (lembar)", default=0) + total_jual_hasil = fields.Monetary(string="Total Jual (hasil)", default=0.0, currency_field='currency_id') + sisa_lembar = fields.Integer(string="Sisa Lembar", compute='_compute_sisa', store=True) + avg_cost = fields.Monetary(string="Avg Cost/Lembar", compute='_compute_avg', store=True, currency_field='currency_id') + harga_sekarang = fields.Monetary(string="Harga Sekarang/Lembar", currency_field='currency_id') + nilai_sekarang = fields.Monetary( + string="Nilai Sekarang", compute='_compute_nilai', store=True, currency_field='currency_id') + realized_pl = fields.Monetary( + string="Realized P/L", currency_field='currency_id') + unrealized_pl = fields.Monetary( + string="Unrealized P/L", compute='_compute_pl', store=True, currency_field='currency_id') + currency_id = fields.Many2one('res.currency', string="Mata Uang", default=lambda self: self.env.company.currency_id) + + _sql_constraints = [ + ('portofolio_saham_uniq', 'unique(saham_id)', "Sudah ada portofolio untuk saham ini."), + ] + + # --- aggregate helper (dijalankan saat rekalkulasi) --- + def action_recalculate(self): + """Rekalkulasi semua posisi portofolio dari transaksi. Return dict ringkas.""" + self.env['saat.portofolio'].search([]).unlink() + trans = self.env['saat.transaksi'].search([], order='tanggal, id') + per_saham = {} + for t in trans: + a = per_saham.setdefault(t.saham_id.id, { + 'saham': t.saham_id, 'beli_lb': 0, 'beli_by': 0.0, + 'jual_lb': 0, 'jual_hl': 0.0, 'moves': [], 'tot_cost': 0.0, + }) + a['moves'].append(t) + if t.tipe == 'buy': + a['beli_lb'] += t.volume + a['beli_by'] += t.total + else: + a['jual_lb'] += t.volume + a['jual_hl'] += t.total # total sudah termasuk fee (keluar) + + portfolios = self.env['saat.portofolio'] + for sid, a in per_saham.items(): + # FIFO realized P/L sederhana: realized saat sell = jual - (avg cost beli * volume terjual) + # avg cost beli per lembar: + avg_beli = (a['beli_by'] / a['beli_lb']) if a['beli_lb'] else 0.0 + lembar_terjual = min(a['jual_lb'], a['beli_lb']) + realized = (a['jual_hl'] - avg_beli * lembar_terjual) if lembar_terjual else 0.0 + sisa = max(a['beli_lb'] - a['jual_lb'], 0) + portfolios.create({ + 'saham_id': sid, + 'total_beli_lembar': a['beli_lb'], + 'total_beli_biaya': a['beli_by'], + 'total_jual_lembar': a['jual_lb'], + 'total_jual_hasil': a['jual_hl'], + 'harga_sekarang': a['saham']._get_last_harga() if hasattr(a['saham'], '_get_last_harga') else 0.0, + 'realized_pl': realized, + }) + return { + 'portofolio_ids': portfolios.ids, + 'count': len(portfolios), + } + + # --- computed store fields --- + @api.depends('total_beli_lembar', 'total_jual_lembar') + def _compute_sisa(self): + for r in self: + r.sisa_lembar = max((r.total_beli_lembar or 0) - (r.total_jual_lembar or 0), 0) + + @api.depends('total_beli_biaya', 'total_beli_lembar') + def _compute_avg(self): + for r in self: + r.avg_cost = (r.total_beli_biaya / r.total_beli_lembar) if r.total_beli_lembar else 0.0 + + @api.depends('sisa_lembar', 'harga_sekarang') + def _compute_nilai(self): + for r in self: + r.nilai_sekarang = (r.sisa_lembar or 0) * (r.harga_sekarang or 0.0) + + @api.depends('harga_sekarang', 'avg_cost', 'sisa_lembar') + def _compute_pl(self): + for r in self: + r.unrealized_pl = ((r.harga_sekarang or 0.0) - (r.avg_cost or 0.0)) * (r.sisa_lembar or 0) + # realized dihitung di action_recalculate (dibawa via currency) — simpan sisa diff --git a/addons/saat_invest/models/saham.py b/addons/saat_invest/models/saham.py new file mode 100644 index 0000000..94ea362 --- /dev/null +++ b/addons/saat_invest/models/saham.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class SaatSaham(models.Model): + """Master daftar saham / emiten yang dicatat.""" + _name = 'saat.saham' + _description = "Saham / Emiten" + _order = 'name' + + name = fields.Char(string="Kode Saham (Ticker)", required=True) + perusahaan = fields.Char(string="Nama Perusahaan / Emiten") + bursa = fields.Char(string="Bursa", default="IDX") + sector = fields.Char(string="Sektor") + active = fields.Boolean(string="Aktif", default=True) + + # o2m convenience menampilkan semua transaksi saham ini + transaksi_ids = fields.One2many('saat.transaksi', 'saham_id', string="Transaksi") + + _sql_constraints = [ + ('saham_uniq', 'unique(name)', "Kode saham sudah terdaftar."), + ] diff --git a/addons/saat_invest/models/transaksi.py b/addons/saat_invest/models/transaksi.py new file mode 100644 index 0000000..217b5ad --- /dev/null +++ b/addons/saat_invest/models/transaksi.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class SaatTransaksi(models.Model): + """Catat setiap pembelian / penjualan saham.""" + _name = 'saat.transaksi' + _description = "Transaksi Jual/Beli Saham" + _order = 'tanggal desc, id desc' + + TIPE = [('buy', 'Beli'), ('sell', 'Jual')] + + name = fields.Char(string="Referensi", compute='_compute_name', store=True) + tanggal = fields.Date(string="Tanggal", required=True, default=fields.Date.context_today) + tipe = fields.Selection(TIPE, string="Tipe", required=True, default='buy') + saham_id = fields.Many2one('saat.saham', string="Saham", required=True, ondelete='restrict') + volume = fields.Integer(string="Jumlah Lembar", required=True) + harga = fields.Monetary(string="Harga/Lembar", currency_field='currency_id', required=True) + fee = fields.Monetary(string="Biaya (Komisi/PPN)", currency_field='currency_id', default=0.0) + total = fields.Monetary( + string="Total Biaya", currency_field='currency_id', + compute='_compute_total', store=True) + catatan = fields.Text(string="Catatan") + user_id = fields.Many2one('res.users', string="Pencatat", default=lambda self: self.env.user) + currency_id = fields.Many2one( + 'res.currency', string="Mata Uang", default=lambda self: self._default_currency()) + + @api.model + def _default_currency(self): + # IDR + return self.env.ref('base.IDR', raise_if_not_found=False) or self.env.company.currency_id + + @api.depends('tanggal', 'tipe', 'saham_id') + def _compute_name(self): + for r in self: + ticker = r.saham_id.name or '' + tipe_label = dict(self.TIPE).get(r.tipe, '') + r.name = f"{r.tanggal or ''} {tipe_label} {ticker}" + + @api.depends('volume', 'harga', 'fee', 'tipe') + def _compute_total(self): + for r in self: + r.total = (r.volume or 0) * (r.harga or 0) + (r.fee or 0) + + @api.constrains('volume', 'harga') + def _check_positive(self): + for r in self: + if r.volume <= 0: + raise models.ValidationError("Volume harus lebih dari 0.") + if r.harga < 0: + raise models.ValidationError("Harga tidak boleh negatif.") diff --git a/addons/saat_invest/security/ir.model.access.csv b/addons/saat_invest/security/ir.model.access.csv new file mode 100644 index 0000000..e6ae0ee --- /dev/null +++ b/addons/saat_invest/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_saat_saham_all,saat.saham.all,model_saat_saham,base.group_user,1,1,1,1 +access_saat_transaksi_all,saat.transaksi.all,model_saat_transaksi,base.group_user,1,1,1,1 +access_saat_portofolio_all,saat.portofolio.all,model_saat_portofolio,base.group_user,1,1,1,1 diff --git a/addons/saat_invest/views/portofolio_views.xml b/addons/saat_invest/views/portofolio_views.xml new file mode 100644 index 0000000..40f519f --- /dev/null +++ b/addons/saat_invest/views/portofolio_views.xml @@ -0,0 +1,62 @@ + + + + saat.portofolio.list + saat.portofolio + + + + + + + + + + + + + + + saat.portofolio.form + saat.portofolio + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Portofolio + saat.portofolio + list,form + +

+ Klik Rekalkulasi (menu Form) untuk memperbarui posisi dari transaksi. +

+
+
+ + +
diff --git a/addons/saat_invest/views/saham_views.xml b/addons/saat_invest/views/saham_views.xml new file mode 100644 index 0000000..8472ada --- /dev/null +++ b/addons/saat_invest/views/saham_views.xml @@ -0,0 +1,59 @@ + + + + saat.saham.list + saat.saham + + + + + + + + + + + + saat.saham.form + saat.saham + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Saham + saat.saham + list,form + +

Daftar saham/emiten yang dicatat.

+
+
+ + + +
diff --git a/addons/saat_invest/views/transaksi_views.xml b/addons/saat_invest/views/transaksi_views.xml new file mode 100644 index 0000000..be341c2 --- /dev/null +++ b/addons/saat_invest/views/transaksi_views.xml @@ -0,0 +1,55 @@ + + + + saat.transaksi.list + saat.transaksi + + + + + + + + + + + + + + + saat.transaksi.form + saat.transaksi + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + Transaksi + saat.transaksi + list,form + +

Catat pembelian / penjualan saham di sini.

+
+
+ + +
diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..30f6ed7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +# Docker Compose untuk Odoo 17 (Community) + PostgreSQL 16 +# Proyek: Aplikasi catat pembelian/penjualan saham +# Lokasi: /home/ardhi/Application/saat-app +# +# Catatan: +# - HANYA container baru, TIDAK menyentuh traefik/gitea/portainer yang sudah ada. +# - Postgres port 5432 TIDAK dipublish ke host (hanya akses antar container). +# - Odoo ter-expose di host port 8069. Traefik routing opsional belakangan. +# - Buat project admin di Odoo langsung via web UI setelah `docker compose up`. + +services: + db: + image: postgres:16 + container_name: saat-odoo-db + restart: unless-stopped + environment: + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + volumes: + - saat_odoo_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - saat-odoo-net + + odoo: + image: odoo:17.0 + container_name: saat-odoo + restart: unless-stopped + depends_on: + db: + condition: service_healthy + ports: + - "8069:8069" + environment: + - HOST=db + - USER=${POSTGRES_USER} + - PASSWORD=${POSTGRES_PASSWORD} + - ADMIN_PASSWORD=${ADMIN_PASSWD} + volumes: + - saat_odoo_filestore:/var/lib/odoo + - ./addons:/mnt/extra-addons + networks: + - saat-odoo-net + +volumes: + saat_odoo_pgdata: + saat_odoo_filestore: + +networks: + saat-odoo-net: + driver: bridge