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)
This commit is contained in:
2026-09-02 11:23:39 +07:00
commit d657a0a8f0
19 changed files with 608 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -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,
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="saham_bbri" model="saat.saham">
<field name="name">BBRI</field>
<field name="perusahaan">Bank Rakyat Indonesia Tbk</field>
<field name="bursa">IDX</field>
<field name="sector">Perbankan</field>
</record>
<record id="saham_tlkm" model="saat.saham">
<field name="name">TLKM</field>
<field name="perusahaan">Telkom Indonesia Tbk</field>
<field name="bursa">IDX</field>
<field name="sector">Telekomunikasi</field>
</record>
<record id="saham_bbca" model="saat.saham">
<field name="name">BBCA</field>
<field name="perusahaan">Bank Central Asia Tbk</field>
<field name="bursa">IDX</field>
<field name="sector">Perbankan</field>
</record>
<record id="saham_asii" model="saat.saham">
<field name="name">ASII</field>
<field name="perusahaan">Astra International Tbk</field>
<field name="bursa">IDX</field>
<field name="sector">Otomotif</field>
</record>
<record id="saham_ptba" model="saat.saham">
<field name="name">PTBA</field>
<field name="perusahaan">Bukit Asam Tbk</field>
<field name="bursa">IDX</field>
<field name="sector">Energi / Batu Bara</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import saham
from . import transaksi
from . import portofolio

View File

@@ -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

View File

@@ -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."),
]

View File

@@ -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.")

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_saat_saham_all saat.saham.all model_saat_saham base.group_user 1 1 1 1
3 access_saat_transaksi_all saat.transaksi.all model_saat_transaksi base.group_user 1 1 1 1
4 access_saat_portofolio_all saat.portofolio.all model_saat_portofolio base.group_user 1 1 1 1

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_saat_portofolio_list" model="ir.ui.view">
<field name="name">saat.portofolio.list</field>
<field name="model">saat.portofolio</field>
<field name="arch" type="xml">
<tree string="Portofolio Saham" create="false" delete="false">
<field name="saham_id"/>
<field name="sisa_lembar"/>
<field name="avg_cost"/>
<field name="harga_sekarang"/>
<field name="nilai_sekarang"/>
<field name="realized_pl" widget="monetary"/>
<field name="unrealized_pl" widget="monetary"/>
</tree>
</field>
</record>
<record id="view_saat_portofolio_form" model="ir.ui.view">
<field name="name">saat.portofolio.form</field>
<field name="model">saat.portofolio</field>
<field name="arch" type="xml">
<form string="Portofolio">
<sheet>
<group>
<field name="saham_id" readonly="1"/>
<field name="harga_sekarang"/>
</group>
<group string="Posisi">
<group>
<field name="total_beli_lembar"/>
<field name="total_beli_biaya"/>
<field name="total_jual_lembar"/>
<field name="total_jual_hasil"/>
</group>
<group>
<field name="sisa_lembar"/>
<field name="avg_cost"/>
<field name="nilai_sekarang"/>
<field name="realized_pl"/>
<field name="unrealized_pl"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_saat_portofolio" model="ir.actions.act_window">
<field name="name">Portofolio</field>
<field name="res_model">saat.portofolio</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Klik <b>Rekalkulasi</b> (menu Form) untuk memperbarui posisi dari transaksi.
</p>
</field>
</record>
<menuitem id="menu_saat_portofolio" name="Portofolio" parent="menu_saat_root"
action="action_saat_portofolio" sequence="30"/>
</odoo>

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_saat_saham_list" model="ir.ui.view">
<field name="name">saat.saham.list</field>
<field name="model">saat.saham</field>
<field name="arch" type="xml">
<tree string="Daftar Saham" create="true">
<field name="name"/>
<field name="perusahaan"/>
<field name="bursa"/>
<field name="sector"/>
</tree>
</field>
</record>
<record id="view_saat_saham_form" model="ir.ui.view">
<field name="name">saat.saham.form</field>
<field name="model">saat.saham</field>
<field name="arch" type="xml">
<form string="Saham">
<sheet>
<group>
<field name="name"/>
<field name="perusahaan"/>
<field name="bursa"/>
<field name="sector"/>
<field name="active"/>
</group>
<notebook>
<page string="Transaksi">
<field name="transaksi_ids">
<tree>
<field name="tanggal"/>
<field name="tipe"/>
<field name="volume"/>
<field name="harga"/>
<field name="total"/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_saat_saham" model="ir.actions.act_window">
<field name="name">Saham</field>
<field name="res_model">saat.saham</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Daftar saham/emiten yang dicatat.</p>
</field>
</record>
<menuitem id="menu_saat_root" name="Investasi Saham" sequence="50"/>
<menuitem id="menu_saat_saham" name="Saham" parent="menu_saat_root"
action="action_saat_saham" sequence="10"/>
</odoo>

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_saat_transaksi_list" model="ir.ui.view">
<field name="name">saat.transaksi.list</field>
<field name="model">saat.transaksi</field>
<field name="arch" type="xml">
<tree string="Transaksi Jual/Beli" create="true">
<field name="tanggal"/>
<field name="tipe"/>
<field name="saham_id"/>
<field name="volume"/>
<field name="harga"/>
<field name="fee"/>
<field name="total"/>
</tree>
</field>
</record>
<record id="view_saat_transaksi_form" model="ir.ui.view">
<field name="name">saat.transaksi.form</field>
<field name="model">saat.transaksi</field>
<field name="arch" type="xml">
<form string="Transaksi">
<sheet>
<group>
<group>
<field name="tanggal"/>
<field name="tipe"/>
<field name="saham_id"/>
<field name="volume"/>
<field name="harga"/>
</group>
<group>
<field name="fee"/>
<field name="total" readonly="1"/>
<field name="catatan" colspan="2"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_saat_transaksi" model="ir.actions.act_window">
<field name="name">Transaksi</field>
<field name="res_model">saat.transaksi</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Catat pembelian / penjualan saham di sini.</p>
</field>
</record>
<menuitem id="menu_saat_transaksi" name="Transaksi" parent="menu_saat_root"
action="action_saat_transaksi" sequence="20"/>
</odoo>