feat: accounts: add Gain (G) account type as subtype of Revenue [#2522]

Add a new account type Gain with single-letter code G as a subtype of
Revenue, similar to how Cash is a subtype of Asset and Conversion is a
subtype of Equity. This enables tracking capital gains/losses separately
while still including them in income statements and close --retain.

Usage: account revenues:capital  ; type: G

- type:G matches only Gain accounts
- type:R matches both Revenue and Gain (subtype matching)
- Auto-detection from account names matching:
  ^(income|revenue)s?:(capital[- ]?)?(gains?|loss(es)?)(:|$)
  e.g. income:gains, revenue:capital-gains, income:losses
This commit is contained in:
g. nicholas d'andrea 2026-01-08 19:12:39 -05:00 committed by Simon Michael
parent c7732c7600
commit e678e09704
8 changed files with 151 additions and 12 deletions

View File

@ -30,6 +30,7 @@ module Hledger.Data.AccountName (
,equityAccountRegex
,conversionAccountRegex
,revenueAccountRegex
,gainAccountRegex
,expenseAccountRegex
,acctsep
,acctsepchar
@ -99,6 +100,7 @@ liabilityAccountRegex = toRegexCI' "^(debts?|liabilit(y|ies))(:|$)"
equityAccountRegex = toRegexCI' "^equity(:|$)"
conversionAccountRegex = toRegexCI' "^equity:(trade|trades|trading|conversion)(:|$)"
revenueAccountRegex = toRegexCI' "^(income|revenue)s?(:|$)"
gainAccountRegex = toRegexCI' "^(income|revenue)s?:(capital[- ]?)?(gains?|loss(es)?)(:|$)"
expenseAccountRegex = toRegexCI' "^expenses?(:|$)"
-- | Try to guess an account's type from its name,
@ -110,6 +112,7 @@ accountNameInferType a
| regexMatchText liabilityAccountRegex a = Just Liability
| regexMatchText conversionAccountRegex a = Just Conversion
| regexMatchText equityAccountRegex a = Just Equity
| regexMatchText gainAccountRegex a = Just Gain
| regexMatchText revenueAccountRegex a = Just Revenue
| regexMatchText expenseAccountRegex a = Just Expense
| otherwise = Nothing
@ -447,6 +450,13 @@ tests_AccountName = testGroup "AccountName" [
accountNameInferType "revenues" @?= Just Revenue
accountNameInferType "revenue" @?= Just Revenue
accountNameInferType "income" @?= Just Revenue
accountNameInferType "income:gains" @?= Just Gain
accountNameInferType "revenue:gain" @?= Just Gain
accountNameInferType "revenues:capital-gains" @?= Just Gain
accountNameInferType "income:capitalgain" @?= Just Gain
accountNameInferType "income:losses" @?= Just Gain
accountNameInferType "revenue:capital-loss" @?= Just Gain
accountNameInferType "income:gains:realized" @?= Just Gain
,testCase "joinAccountNames" $ do
joinAccountNames "assets" "cash" @?= "assets:cash"
joinAccountNames "assets:cash" "a" @?= "assets:cash:a"

View File

@ -181,6 +181,7 @@ data AccountType =
| Expense
| Cash -- ^ a subtype of Asset - liquid assets to show in cashflow report
| Conversion -- ^ a subtype of Equity - account with which to balance commodity conversions
| Gain -- ^ a subtype of Revenue - capital gains/losses
deriving (Eq,Ord,Generic)
instance Show AccountType where
@ -191,6 +192,7 @@ instance Show AccountType where
show Expense = "X"
show Cash = "C"
show Conversion = "V"
show Gain = "G"
isBalanceSheetAccountType :: AccountType -> Bool
isBalanceSheetAccountType t = t `elem` [
@ -204,7 +206,8 @@ isBalanceSheetAccountType t = t `elem` [
isIncomeStatementAccountType :: AccountType -> Bool
isIncomeStatementAccountType t = t `elem` [
Revenue,
Expense
Expense,
Gain
]
-- | Check whether the first argument is a subtype of the second: either equal
@ -219,6 +222,8 @@ isAccountSubtypeOf Cash Cash = True
isAccountSubtypeOf Cash Asset = True
isAccountSubtypeOf Conversion Conversion = True
isAccountSubtypeOf Conversion Equity = True
isAccountSubtypeOf Gain Gain = True
isAccountSubtypeOf Gain Revenue = True
isAccountSubtypeOf _ _ = False
-- not worth the trouble, letters defined in accountdirectivep for now

View File

@ -517,8 +517,8 @@ accountTypeChoices :: Bool -> String
accountTypeChoices allowlongform =
intercalate ", "
-- keep synced with parseAccountType
$ ["A","L","E","R","X","C","V"]
++ if allowlongform then ["Asset","Liability","Equity","Revenue","Expense","Cash","Conversion"] else []
$ ["A","L","E","R","X","C","V","G"]
++ if allowlongform then ["Asset","Liability","Equity","Revenue","Expense","Cash","Conversion","Gain"] else []
-- | Case-insensitively parse one single-letter code, or one long-form word if permitted, to an account type.
-- On failure, returns the unparseable text.
@ -533,6 +533,7 @@ parseAccountType allowlongform s =
"x" -> Right Expense
"c" -> Right Cash
"v" -> Right Conversion
"g" -> Right Gain
"asset" | allowlongform -> Right Asset
"liability" | allowlongform -> Right Liability
"equity" | allowlongform -> Right Equity
@ -540,6 +541,7 @@ parseAccountType allowlongform s =
"expense" | allowlongform -> Right Expense
"cash" | allowlongform -> Right Cash
"conversion" | allowlongform -> Right Conversion
"gains" | allowlongform -> Right Gain
_ -> Left $ T.unpack s
-- | Parse the value part of a "status:" query, or return an error.

View File

@ -563,10 +563,12 @@ parseAccountTypeCode s =
"c" -> Right Cash
"conversion" -> Right Conversion
"v" -> Right Conversion
"gains" -> Right Gain
"g" -> Right Gain
_ -> Left err
where
err = T.unpack $ "invalid account type code "<>s<>", should be one of " <>
T.intercalate ", " ["A","L","E","R","X","C","V","Asset","Liability","Equity","Revenue","Expense","Cash","Conversion"]
T.intercalate ", " ["A","L","E","R","X","C","V","G","Asset","Liability","Equity","Revenue","Expense","Cash","Conversion","Gain"]
-- Add an account declaration to the journal, auto-numbering it.
addAccountDeclaration :: (AccountName,Text,[Tag],SourcePos) -> JournalParser m ()

View File

@ -2212,16 +2212,17 @@ and two more representing changes in these:
| `Revenue` | `R` | inflows (also known as `Income`) |
| `Expense` | `X` | outflows |
hledger also uses a couple of subtypes:
hledger also uses a few subtypes:
||||
|-|-|-|
| `Cash` | `C` | liquid assets |
| `Conversion` | `V` | commodity conversions equity |
| `Cash` | `C` | liquid assets (subtype of Asset) |
| `Conversion` | `V` | commodity conversions equity (subtype of Equity) |
| `Gain` | `G` | capital gains/losses (subtype of Revenue) |
<!-- [liquid assets]: https://en.wikipedia.org/wiki/Cash_and_cash_equivalents -->
As a convenience, hledger will detect these types automatically from english account names.
As a convenience, hledger will detect most of these types automatically from english account names.
But it's better to declare them explicitly by adding a `type:` [tag](#tags) in the account directives.
The tag's value can be any of the types or one-letter abbreviations above.
@ -2239,6 +2240,8 @@ account assets:bank ; type: C
account assets:cash ; type: C
account equity:conversion ; type: V
account revenues:capital ; type: G
```
This enables the easy [balancesheet], [balancesheetequity], [cashflow] and [incomestatement] reports, and querying by [type:](#queries).
@ -5682,8 +5685,9 @@ Match unmarked, pending, or cleared transactions respectively.
**`type:TYPECODES`**\
Match by account type (see [Declaring accounts > Account types](#account-types)).
`TYPECODES` is one or more of the single-letter account type codes
`ALERXCV`, case insensitive.
Note `type:A` and `type:E` will also match their respective subtypes `C` (Cash) and `V` (Conversion).
`ALERXCVG`, case insensitive.
Note `type:A`, `type:E`, and `type:R` will also match their respective subtypes
`C` (Cash), `V` (Conversion), and `G` (Gain).
Certain kinds of account alias can disrupt account types, see
[Rewriting accounts > Aliases and account types](#aliases-and-account-types).

View File

@ -274,3 +274,23 @@ $ hledger -f- close --clopen -e 2001 --round=hard -c '$1.0'
equity:opening/closing balances
>=
# ** 20. With --retain, Gain accounts (subtype of Revenue) are also closed.
<
account revenues ; type:R
account gains ; type:G
account expenses ; type:X
2016/1/1
revenues $-100
gains $-50
expenses $150
$ hledger close -f- -e 2017 --retain
2016-12-31 retain earnings ; retain:
revenues $100 = $0
gains $50 = $0
expenses $-150 = $0
equity:retained earnings
>=0

View File

@ -123,6 +123,7 @@ account a:aa:aaa ; type:L
(a:aa) 1
(a:aa:aaa) 1
# ** 6. bs will detect proper accounts even with an intervening parent account (#1921)
$ hledger -f- bs -N
Balance Sheet 2021-01-01
@ -136,3 +137,63 @@ Balance Sheet 2021-01-01
-------------++------------
a || -1
a:aa:aaa || -1
# ** 7. Gains accounts appear in income statement as revenue subtype
<
account revenues ; type:R
account gains ; type:G
account expenses ; type:X
2020-01-01
revenues -100
gains -50
expenses 150
$ hledger -f- is
Income Statement 2020-01-01
|| 2020-01-01
==========++============
Revenues ||
----------++------------
revenues || 100
gains || 50
----------++------------
|| 150
==========++============
Expenses ||
----------++------------
expenses || 150
----------++------------
|| 150
==========++============
Net: || 0
# ** 8. Gain accounts are auto-detected from common naming patterns
<
2020-01-01
income:gains -50
revenue:capital-gains -30
income:losses -20
expenses 100
$ hledger -f- is
Income Statement 2020-01-01
|| 2020-01-01
=======================++============
Revenues ||
-----------------------++------------
income:gains || 50
income:losses || 20
revenue:capital-gains || 30
-----------------------++------------
|| 100
=======================++============
Expenses ||
-----------------------++------------
expenses || 100
-----------------------++------------
|| 100
=======================++============
Net: || 0

View File

@ -135,3 +135,38 @@ $ hledger -f- reg type:ae
2022-02-02 Test (assets:cash) 1 1
(equity:conversion) 2 3
(equity:conversion) -2 1
# ** 16. type:g matches gains accounts
<
account gains ; type:G
2022-02-02 Test
(gains) 1
$ hledger -f- accounts type:g
gains
# ** 17. type:r matches both revenue and gains (subtype matching)
<
account revenue ; type:R
account gains ; type:G
2022-02-02 Test
(revenue) 1
(gains) 1
$ hledger -f- accounts type:r
revenue
gains
# ** 18. type:g matches auto-detected gain accounts
<
2022-02-02 Test
(income:gains) 1
(revenue:capital-gains) 1
(income:losses) 1
$ hledger -f- accounts type:g
income:gains
income:losses
revenue:capital-gains