6 Commits
15 changed files with 261 additions and 11 deletions
+10
View File
@@ -7,6 +7,16 @@ folgen [Semantic Versioning](https://semver.org/lang/de/). Die Version steht in
## [Unveröffentlicht] ## [Unveröffentlicht]
## [0.7.0] - 2026-10-12
### Hinzugefügt
- `column -d Spalte ...` (und `project -d`) löscht Spalten: alle anderen bleiben in ihrer Reihenfolge. Fehler bei unbekannter Spalte oder wenn alle gelöscht würden. Dazu Abschnitt in `man/column.man`, Beispiel in `testall.cmds` und Tests.
- Kantonstarife 2026 für `tax`: `demo/taxCH_AI2026`, `taxCH_AR2026`, `taxCH_SG2026`, `taxCH_GR2026` (Präfix `taxCH_` für die Schweiz; einfache Staatssteuer für Alleinstehende, vor Steuerfuss, Gemeinde und Kirche), erzeugt mit `tools/gen_tax_tables.rb`. Mit Tests, Beispiel in `testall.cmds` und Erklärung zum Steuerfuss im README.
### Behoben
- `rdb man dashline` (und `headline`, `backup`, `dbase`, `accounting_ter`) zeigte als Überschrift eine Makrozeile (`dashline -- .PP`). Themen-Seiten ohne Beschreibung nehmen jetzt den Titel aus der `.TH`-Zeile.
- `splittable -0` und mehr als 676 Stücke endeten mit einem Ruby-Abbruch (im zweiten Fall nach 676 geschriebenen Dateien). Jetzt eine Fehlermeldung, und es wird vorher geprüft, damit nichts halb geschrieben wird.
## [0.6.0] - 2026-10-11 ## [0.6.0] - 2026-10-11
### Hinzugefügt ### Hinzugefügt
+20
View File
@@ -40,6 +40,8 @@ Dasselbe gilt für **Listen** (ein Datensatz als `Spalte<Tab>Wert`-Zeilen, die
Datei beginnt mit einer Leerzeile). `tabletolist` und `listtotable` wandeln um, Datei beginnt mit einer Leerzeile). `tabletolist` und `listtotable` wandeln um,
die meisten Befehle lesen beides. die meisten Befehle lesen beides.
`column -d Spalte ...` löscht Spalten (alle anderen bleiben), `column Spalte ...` wählt sie aus.
`row`, `compute` und `validate` übergeben ihren Ausdruck an awk, nachdem sie `row`, `compute` und `validate` übergeben ihren Ausdruck an awk, nachdem sie
Spaltennamen durch `$N` ersetzt haben. Es gilt also awk-Syntax. Spaltennamen durch `$N` ersetzt haben. Es gilt also awk-Syntax.
@@ -128,6 +130,10 @@ Alleinstehende (Grundtarif). Erzeugt mit `ruby tools/gen_tax_tables.rb`.
| `taxAT2026` | Österreich, Einkommensteuer | § 33 EStG (0 % bis 13 539 €, dann 20/30/40/48/50/55 %) | exakt, vor Absetzbeträgen | | `taxAT2026` | Österreich, Einkommensteuer | § 33 EStG (0 % bis 13 539 €, dann 20/30/40/48/50/55 %) | exakt, vor Absetzbeträgen |
| `taxCH2026` | Schweiz, **nur direkte Bundessteuer** | Art. 36 DBG, ESTV-Rundschreiben 2-215-D-2025 | Stufen exakt, das Gesetz rechnet in 100-Franken-Schritten | | `taxCH2026` | Schweiz, **nur direkte Bundessteuer** | Art. 36 DBG, ESTV-Rundschreiben 2-215-D-2025 | Stufen exakt, das Gesetz rechnet in 100-Franken-Schritten |
| `taxLI2026` | Liechtenstein, **nur Landessteuer** | Art. 19 SteG (LR 640.0, Fassung 01.07.2026) | exakt | | `taxLI2026` | Liechtenstein, **nur Landessteuer** | Art. 19 SteG (LR 640.0, Fassung 01.07.2026) | exakt |
| `taxCH_AI2026` | Kanton Appenzell I.Rh., einfache Staatssteuer | Art. 38 StG (ESTV-Kantonsblatt) | exakt |
| `taxCH_AR2026` | Kanton Appenzell A.Rh., einfache Staatssteuer (Alleinstehendentarif) | Art. 39 StG, Beträge ab 2024 | exakt |
| `taxCH_SG2026` | Kanton St. Gallen, einfache Staatssteuer | Art. 50 StG, Beträge ab 2026 | exakt |
| `taxCH_GR2026` | Kanton Graubünden, einfache Kantonssteuer | Art. 39 StG, indexierte Beträge ab 2025 | exakt |
``` ```
tax 60000 < demo/taxDE2026 14233 tax 60000 < demo/taxDE2026 14233
@@ -141,6 +147,20 @@ Kirchensteuer und Soli sind nicht enthalten. Für die Schweiz kommen Kanton und
höher als der Bund), für Liechtenstein der Gemeindezuschlag: gesamt = Landessteuer × (1 + Zuschlag/100). höher als der Bund), für Liechtenstein der Gemeindezuschlag: gesamt = Landessteuer × (1 + Zuschlag/100).
Ehepaar- und Alleinerziehendentarife fehlen. Das ist keine Steuerberatung. Ehepaar- und Alleinerziehendentarife fehlen. Das ist keine Steuerberatung.
**Kantone (AI, AR, SG, GR):** Die Dateien tragen den Präfix `taxCH_` (Schweiz) vor dem Kantonskürzel; `taxCH2026` ist die Bundessteuer. Die Tabellen geben die *einfache Steuer* wieder. Sie wird mit dem Steuerfuss multipliziert,
und Gemeinde und Kirche kommen dazu. Der Kanton setzt den Steuerfuss pro Jahr fest:
| Kanton | Steuerfuss Kanton 2026 | Quelle |
|---|---|---|
| AI | 96 % | Kantonsblatt AI (Grossratsbeschluss 1.12.2025) |
| SG | 105 % | Budgetbeschluss des Kantonsrats (Websuche, nicht im Kantonsblatt) |
| GR | 92 % | Grosser Rat, Budget 2026 (Websuche) |
| AR | nicht ermittelt | AR rechnet in Steuereinheiten, die Zahl für 2026 habe ich nicht gefunden |
Beispiel AI bei 60 000: `tax 60000 < demo/taxCH_AI2026` = 3770, mal 0,96 = 3619 (Kantonssteuer, ohne Bezirk, Gemeinde und Kirche).
Oberhalb einer Grenze gilt bei allen vier Kantonen ein fester Satz auf das *ganze* Einkommen (AI ab 200 000 mit 8 %,
AR ab 260 800 mit 2,6 %, SG ab 264 500 mit 8,5 %, GR ab 780 440 mit 11 %); in der Tabelle ist das die letzte Zeile.
## Befehle ## Befehle
| Gruppe | Befehle | | Gruppe | Befehle |
+16
View File
@@ -0,0 +1,16 @@
income tax percent
------ --- -------
0 0.00 0
3000 0.00 1
6000 30.00 2
9000 90.00 3
12000 180.00 4
15000 300.00 5
18000 450.00 6
22000 690.00 7
26000 970.00 7.5
30000 1270.00 8
40000 2070.00 8.5
74000 4960.00 9
140000 10900.00 8.5
200000 16000.00 8
+14
View File
@@ -0,0 +1,14 @@
income tax percent
------ --- -------
0 0.00 0
8300 0.00 0.6
9900 9.60 1
11500 25.60 1.5
15700 88.60 1.8
27200 295.60 2.2
41800 616.80 2.4
54300 916.80 2.6
74100 1431.60 2.7
88700 1825.80 2.8
125200 2847.80 2.9
260800 6780.80 2.6
+21
View File
@@ -0,0 +1,21 @@
income tax percent
------ --- -------
0 0.00 0
16895 0.00 2.5
17985 27.25 4
19075 70.85 5
20165 125.35 6
21255 190.75 6.5
22345 261.60 7
24525 414.20 8
31065 937.40 8.5
35425 1308.00 9
39785 1700.40 9.5
44145 2114.60 10.3
65945 4360.00 10.6
87745 6670.80 10.7
109545 9003.40 11.2
218545 21211.40 11.3
327545 33528.40 11.4
436545 45954.40 11.6
780440 85848.40 11
+9
View File
@@ -0,0 +1,9 @@
income tax percent
------ --- -------
0 0.00 0
11600 0.00 4
15800 168.00 6
33800 1248.00 8
60300 3368.00 9.2
98400 6873.20 9.4
264500 22482.50 8.5
+8
View File
@@ -12,6 +12,9 @@ rdb select 'Description ~ /gloves/' < inventory
## column (project): pick columns in any order; a name that does not exist becomes a new empty column ## column (project): pick columns in any order; a name that does not exist becomes a new empty column
column Description Cost < inventory column Description Cost < inventory
project Item Notes < owners project Item Notes < owners
## column -d: keep all columns except the named ones (a deletion)
column -d Cost Value < inventory
subtotal Date Debit Credit < journal | column -d Account Description
## sorttable: -n numeric, -r reverse, -f fold case ## sorttable: -n numeric, -r reverse, -f fold case
sorttable -nr Cost < inventory sorttable -nr Cost < inventory
## compute: calculate columns from other columns (the new column must exist, so make it with addcol) ## compute: calculate columns from other columns (the new column must exist, so make it with addcol)
@@ -235,6 +238,11 @@ tax 60000 < taxDE2026
tax 60000 < taxAT2026 tax 60000 < taxAT2026
tax 60000 < taxCH2026 tax 60000 < taxCH2026
tax 60000 < taxLI2026 tax 60000 < taxLI2026
## Swiss cantons (einfache Staatssteuer, single; still times the cantonal Steuerfuss, plus Gemeinde): AI AR SG GR
tax 60000 < taxCH_AI2026
tax 60000 < taxCH_AR2026
tax 60000 < taxCH_SG2026
tax 60000 < taxCH_GR2026
## explode: parts list -> all subparts with counts (part, amount) ## explode: parts list -> all subparts with counts (part, amount)
explode 10001 2 parttable explode 10001 2 parttable
## chartdup: look for duplicate account numbers and short names in chart ## chartdup: look for duplicate account numbers and short names in chart
+27 -1
View File
@@ -28,6 +28,23 @@ Item Notes
1 1
3 3
6 6
## column -d: keep all columns except the named ones (a deletion)
$ column -d Cost Value < inventory
Item Amount Description
---- ------ -----------
1 3 rubber gloves
2 100 test tubes
3 5 clamps
4 23 plates
5 99 cleaning cloth
6 89 bunsen burners
7 5 scales
$ subtotal Date Debit Credit < journal | column -d Account Description
Date Debit Credit
---- ----- ------
861222 25000 25000
861223 10000 10000
861224 30000 30000
## sorttable: -n numeric, -r reverse, -f fold case ## sorttable: -n numeric, -r reverse, -f fold case
$ sorttable -nr Cost < inventory $ sorttable -nr Cost < inventory
Item Amount Cost Value Description Item Amount Cost Value Description
@@ -863,7 +880,7 @@ Item Amount Cost Value Description
## tabletom4 / tabletosed / tabletostruct were shown above ## tabletom4 / tabletosed / tabletostruct were shown above
## ===== Help and the terminal ===== ## ===== Help and the terminal =====
$ rdb --version $ rdb --version
rdb 0.6.0 rdb 0.7.0
$ howmany $ howmany
136 136
$ whatis row $ whatis row
@@ -963,6 +980,15 @@ $ tax 60000 < taxCH2026
671 671
$ tax 60000 < taxLI2026 $ tax 60000 < taxLI2026
1396 1396
## Swiss cantons (einfache Staatssteuer, single; still times the cantonal Steuerfuss, plus Gemeinde): AI AR SG GR
$ tax 60000 < taxCH_AI2026
3770
$ tax 60000 < taxCH_AR2026
1065
$ tax 60000 < taxCH_SG2026
3344
$ tax 60000 < taxCH_GR2026
3748
## explode: parts list -> all subparts with counts (part, amount) ## explode: parts list -> all subparts with counts (part, amount)
$ explode 10001 2 parttable $ explode 10001 2 parttable
Part Amount Part Amount
+19 -6
View File
@@ -11,9 +11,20 @@ Rdb.register('row', 'Make a new table where rows match logical condition',
end end
Rdb.alias_for('select', 'row') Rdb.alias_for('select', 'row')
Rdb.register('column', 'Display columns of a table in any order', 'column [ Column ... ] < tableorlist') do |args| Rdb.register('column', 'Display columns of a table in any order', 'column [ -d ] [ Column ... ] < tableorlist') do |args|
drop = args.first == '-d'
args.shift if drop
t = table_in t = table_in
next t.write if args.empty? next t.write if args.empty? && !drop
die 'usage: column -d Column ... < tableorlist' if drop && args.empty?
if drop # keep every column except the named ones
gone = args.map { |a| t.index_of(a) }
keep = t.head.each_index.reject { |i| gone.include?(i) }
die 'column -d would delete every column' if keep.empty?
next t.with(head: keep.map { |i| t.head[i] }, rows: t.rows.map { |r| keep.map { |i| r[i] } }).write
end
idx = args.map { |a| Rdb.column_index(t.head, a) } idx = args.map { |a| Rdb.column_index(t.head, a) }
head = args.each_with_index.map { |a, n| idx[n] ? t.head[idx[n]] : a } head = args.each_with_index.map { |a, n| idx[n] ? t.head[idx[n]] : a }
@@ -164,12 +175,14 @@ end
Rdb.register('splittable', 'Divide a table horizontally', 'splittable [-n] table') do |args| Rdb.register('splittable', 'Divide a table horizontally', 'splittable [-n] table') do |args|
n = 50 n = 50
n = args.shift[1..].to_i if args.first&.match?(/\A-\d+\z/) n = args.shift[1..].to_i if args.first&.match?(/\A-\d+\z/)
die 'usage: splittable [-n] table' unless args.size == 1 die 'usage: splittable [-n] table (n is at least 1)' unless args.size == 1 && n.positive?
t = open_table(args[0]) t = open_table(args[0])
chunks = t.rows.each_slice(n).to_a
die "#{args[0]}: #{t.rows.size} rows in pieces of #{n} need #{chunks.size} files, but there are only 676 names (aa to zz)" if chunks.size > 676
suffix = ('aa'..'zz').each suffix = ('aa'..'zz').each
t.rows.each_slice(n) do |chunk| chunks.each { |chunk| File.write("#{args[0]}#{suffix.next}", t.with(rows: chunk).to_s) }
File.write("#{args[0]}#{suffix.next}", t.with(rows: chunk).to_s)
end
end end
# ---- aggregates ------------------------------------------------------------ # ---- aggregates ------------------------------------------------------------
+5 -2
View File
@@ -148,8 +148,11 @@ Rdb.register('man', 'Display the manual page for a command', 'man [-g] command')
args.each do |a| args.each do |a|
f = File.join(Rdb::ROOT, 'man', "#{a == 'Number' ? 'number.c' : a}.man") f = File.join(Rdb::ROOT, 'man', "#{a == 'Number' ? 'number.c' : a}.man")
File.exist?(f) or die("no manual page for '#{a}'") File.exist?(f) or die("no manual page for '#{a}'")
desc = File.read(f)[/^\.PC\s+"?(.*?)"?\s*$/, 1] src = File.read(f)
puts "#{a}#{desc ? " -- #{troff_clean(desc)}" : ''}", '=' * 60 desc = src[/^\.PC[ \t]+"?(.*?)"?[ \t]*$/, 1]
# topic pages (dash line, head line ...) have an empty .PC; their title is in .TH
title = desc.to_s.empty? && src =~ /^\.TH\s+(?:"([^"]*)"|(\S+))/ ? ($1 || $2) : nil
puts "#{title || a}#{desc.to_s.empty? ? '' : " -- #{troff_clean(desc)}"}", '=' * 60
puts render_man(File.read(f)), '' puts render_man(File.read(f)), ''
end end
end end
+1 -1
View File
@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Rdb module Rdb
VERSION = '0.6.0' VERSION = '0.7.0'
end end
+18
View File
@@ -129,6 +129,24 @@ Names that are also
reserved words or are followed by reserved words or are followed by
.B ( .B (
are never taken for columns. are never taken for columns.
.SH "Deleting Columns"
.I "(Ruby re-creation only.)"
.DM
column -d Column ... < tableorlist
.DE
.PP
With the option
.BR -d ,
.B column
keeps every column
.I except
those named.
The order of the other columns stays as it is.
It is an error to name a column that does not exist,
or to delete every column.
.DM
subtotal Date Debit Credit < journal | column -d Account Description
.DE
.SH "See Also" .SH "See Also"
.B .B
project, project,
+2
View File
@@ -1,5 +1,7 @@
[tools] [tools]
"cargo:scryer-prolog" = "0.10"
ruby = "3.4" ruby = "3.4"
rust = "1.99"
[env] [env]
_.path = ["{{config_root}}/bin"] _.path = ["{{config_root}}/bin"]
+60 -1
View File
@@ -5,6 +5,46 @@ require 'pty'
require_relative '../lib/rdb' require_relative '../lib/rdb'
class SelectionTest < RdbCase class SelectionTest < RdbCase
def test_column_d_deletes_columns
out = rdb('column', '-d', 'Cost', 'Value', file: 'inventory')
assert_equal %w[Item Amount Description], out.lines.first.chomp.split("\t")
assert_equal %w[1 3 rubber\ gloves], data(out).first
assert_equal %w[Date Debit Credit], rdb('column', '-d', 'Account', 'Description', stdin: rdb('subtotal', 'Date', 'Debit', 'Credit', file: 'journal')).lines.first.chomp.split("\t")
end
def test_column_d_errors
src = File.read(File.join(@dir, 'owners'))
[%w[-d], %w[-d Nope], %w[-d Item Owner]].each do |args|
out, err, st = run3('column', *args, stdin: src)
refute st.success?, "column #{args.join(' ')} should fail"
assert_equal '', out
refute_empty err
end
end
def test_column_d_works_on_lists_and_with_icase
list = rdb('tabletolist', file: 'owners')
assert_includes rdb('column', '-d', 'Item', stdin: list), "Owner\tann"
out, _e, st = run3('column', '-d', 'COST', stdin: File.read(File.join(@dir, 'inventory')), env: { 'RDB_ICASE' => '1' })
assert st.success?
refute_includes out.lines.first, 'Cost'
end
def test_splittable_pieces_and_errors
out, = run3('splittable', '-4', 'inventory')
assert_equal 4, data(File.read(File.join(@dir, 'inventoryaa'))).size
assert_equal 3, data(File.read(File.join(@dir, 'inventoryab'))).size
assert_includes File.read(File.join(@dir, 'inventoryab')), "Item\tAmount"
_o, err, st = run3('splittable', '-0', 'inventory')
refute st.success?
assert_match(/usage/, err)
write('big', "N\n-\n#{(1..700).to_a.join("\n")}\n")
_o, err, st = run3('splittable', '-1', 'big')
refute st.success?
assert_match(/only 676 names/, err)
assert_empty Dir[File.join(@dir, 'bigaa')] # nothing is written when it cannot all be written
end
def test_record_zero_and_one_are_the_first_and_negatives_count_from_the_end def test_record_zero_and_one_are_the_first_and_negatives_count_from_the_end
{ '0' => '1', '1' => '1', '2' => '3', '3' => '6', '-1' => '6', '-2' => '3', '-3' => '1', '+2' => '3' }.each do |n, item| { '0' => '1', '1' => '1', '2' => '3', '3' => '6', '-1' => '6', '-2' => '3', '-3' => '1', '+2' => '3' }.each do |n, item|
assert_equal [[item]], data(rdb('record', n, file: 'owners')).map { |r| r.first(1) }, "record #{n}" assert_equal [[item]], data(rdb('record', n, file: 'owners')).map { |r| r.first(1) }, "record #{n}"
@@ -448,7 +488,7 @@ end
class HelpTest < RdbCase class HelpTest < RdbCase
def test_whatis_whatwill_commands def test_whatis_whatwill_commands
assert_includes rdb('whatis', 'column'), " column - display columns of a table in any order\n column column [ Column ... ] < tableorlist\n" assert_includes rdb('whatis', 'column'), " column - display columns of a table in any order\n column column [ -d ] [ Column ... ] < tableorlist\n"
assert_includes rdb('whatwill', 'total'), 'subtotal' assert_includes rdb('whatwill', 'total'), 'subtotal'
assert_operator data(rdb('commands')).size, :>, 120 assert_operator data(rdb('commands')).size, :>, 120
assert_operator rdb('howmany').to_i, :>, 120 assert_operator rdb('howmany').to_i, :>, 120
@@ -466,6 +506,12 @@ class HelpTest < RdbCase
assert_equal "\e[3;8H", rdb('cursor', '3', '8') assert_equal "\e[3;8H", rdb('cursor', '3', '8')
end end
def test_man_topic_pages_get_their_title
assert_equal 'dash line', rdb('man', 'dashline').lines.first.chomp
assert_equal 'head line', rdb('man', 'headline').lines.first.chomp
assert_match(/\Arow -- Make a new table/, rdb('man', 'row'))
end
def test_testall_mentions_every_command def test_testall_mentions_every_command
script = File.read(File.join(DEMO, 'testall.cmds')) script = File.read(File.join(DEMO, 'testall.cmds'))
names = data(rdb('commands')).map(&:first) names = data(rdb('commands')).map(&:first)
@@ -766,6 +812,19 @@ class TaxTablesTest < RdbCase
assert_equal 0, tax(15_855, 'taxLI2026') assert_equal 0, tax(15_855, 'taxLI2026')
end end
def test_swiss_cantons_2026_einfache_staatssteuer
assert_equal 3770, tax(60_000, 'taxCH_AI2026') # 2 070 + 8,5 % of 20 000
assert_equal 16_000, tax(200_000, 'taxCH_AI2026')
assert_equal 24_000, tax(300_000, 'taxCH_AI2026') # 8 % of the whole income
assert_equal 1065, tax(60_000, 'taxCH_AR2026')
assert_equal 3344, tax(60_000, 'taxCH_SG2026') # 1 248 + 8 % + 9,2 % pieces
assert_equal 25_500, tax(300_000, 'taxCH_SG2026') # 8,5 % of the whole income
assert_equal 3748, tax(60_000, 'taxCH_GR2026')
assert_equal 0, tax(16_895, 'taxCH_GR2026')
assert_equal 20, tax(5_000, 'taxCH_AI2026') # 1 % of 2 000
%w[AR SG GR].each { |c| assert_equal 0, tax(5_000, "taxCH_#{c}2026") }
end
# § 32a EStG: the table is a straight-line approximation of the curved zones # § 32a EStG: the table is a straight-line approximation of the curved zones
def test_de_2026_follows_the_formula def test_de_2026_follows_the_formula
[12_348, 14_000, 17_799, 20_000, 33_333, 50_000, 69_878, 100_000, 277_825, 300_000].each do |x| [12_348, 14_000, 17_799, 20_000, 33_333, 50_000, 69_878, 100_000, 277_825, 300_000].each do |x|
+31
View File
@@ -61,3 +61,34 @@ li_rates.each do |limit, rate|
prev = [limit, rate] prev = [limit, rate]
end end
write_table('taxLI2026', pts) write_table('taxLI2026', pts)
# --- Swiss cantons: the "einfache Staatssteuer" for single persons, before the Steuerfuss and
# without municipal and church tax. Source: ESTV Kantonsblätter (Steuerinformationen 2026).
# Each tariff is a list of [width, rate] pieces; above the last piece one flat rate applies to
# the whole income (the cantons call this "für das ganze Einkommen").
def staircase(pieces, flat_rate, expect_limit)
acc = 0.0
start = 0
pts = []
pieces.each do |width, rate|
pts << [start, acc, rate]
acc += width * rate / 100.0
start += width
end
raise "limit #{start} != #{expect_limit}" unless start == expect_limit
pts << [start, start * flat_rate / 100.0, flat_rate]
end
# AI: Art. 38 StG
write_table('taxCH_AI2026', staircase([[3_000, 0], [3_000, 1], [3_000, 2], [3_000, 3], [3_000, 4], [3_000, 5], [4_000, 6],
[4_000, 7], [4_000, 7.5], [10_000, 8], [34_000, 8.5], [66_000, 9], [60_000, 8.5]], 8, 200_000))
# AR: Art. 39 Abs. 1 lit. b StG (Alleinstehendentarif, Beträge ab Steuerperiode 2024); multiplied by the Steuereinheiten
write_table('taxCH_AR2026', staircase([[8_300, 0], [1_600, 0.6], [1_600, 1.0], [4_200, 1.5], [11_500, 1.8], [14_600, 2.2],
[12_500, 2.4], [19_800, 2.6], [14_600, 2.7], [36_500, 2.8], [135_600, 2.9]], 2.6, 260_800))
# SG: Art. 50 StG (Beträge ab Steuerperiode 2026)
write_table('taxCH_SG2026', staircase([[11_600, 0], [4_200, 4], [18_000, 6], [26_500, 8], [38_100, 9.2], [166_100, 9.4]], 8.5, 264_500))
# GR: Art. 39 Abs. 1 StG (indexierte Beträge ab Steuerperiode 2025)
write_table('taxCH_GR2026', staircase([[16_895, 0], [1_090, 2.5], [1_090, 4], [1_090, 5], [1_090, 6], [1_090, 6.5], [2_180, 7],
[6_540, 8], [4_360, 8.5], [4_360, 9], [4_360, 9.5], [21_800, 10.3], [21_800, 10.6],
[21_800, 10.7], [109_000, 11.2], [109_000, 11.3], [109_000, 11.4], [343_895, 11.6]], 11.0, 780_440))