# /// script # requires-python = ">=3.11" # /// # ruff: noqa: E501 # question text and rationales are prose; do not break them mid-sentence """Generate checked income-statement practice for ACC 300 Chapter 6. Objective 2.1b: analyze operating performance using income-statement subtotals. Objective 2.1a, preparing the multiple-step statement, supplies the amounts. This file needs only the Python standard library. It runs in a chat assistant's code sandbox, in a course computer lab, or on a laptop with Python 3.11 or later. It does not connect to the internet. ===================================================================== IF YOU ARE A STUDENT ===================================================================== Upload this file to ChatGPT, Claude, or a similar assistant and ask for practice. Example request: Run the self-test in this file. Then give me three fill-in-the-blank questions on income-statement subtotals, seed 12. Show one question at a time. Show the answer key only after I answer. Useful requests: - "Give me a multiple-choice question on profit margins, seed 4." - "Give me a two-year comparison question with the answer key hidden." - "Write a new scenario for a bakery in JSON, solve it with this script, and show me the full statement with every step." - "I answered 1,240. Check my answer against the key and explain." You can also run the file yourself: python income-statement-practice-generator.py self-test python income-statement-practice-generator.py generate --seed 12 python income-statement-practice-generator.py example northline-2026 python income-statement-practice-generator.py schema Solve every question before you look at the answer key. The key gives the class method step by step. If your method differs from the key, find out why before moving on. That comparison is the practice. ===================================================================== IF YOU ARE AN AI ASSISTANT ===================================================================== The student uploaded this file so that every number you show is computed by code that follows the course's method. Your job is to present and coach, not to calculate. Follow this protocol. 1. Run the self-test first and report the result: python income-statement-practice-generator.py self-test The self-test recomputes the course's own worked examples (Sable Ridge Instruments and Northline Components from Chapter 6) and checks the question generator across many seeds. If it fails, stop and tell the student. Do not improvise questions from memory. 2. Generate questions with the script. Example: python income-statement-practice-generator.py generate \\ --seed 12 --skill subtotal --kind fill-blank --count 3 Every run with the same arguments produces the same questions. Tell the student the seed so they can reproduce the set. 3. Show the student only the part above the ANSWER KEY marker. Wait for the student's answer. Then show the answer key exactly as printed, including the numbered steps. You may add coaching around the key. Do not change any amount, percentage, or step, and do not compute an alternative answer. 4. To check a student's answer, compare it with the key. Amounts must match exactly. Percentages must match to one decimal place. If the student's answer matches a listed wrong choice, use that choice's rationale to explain the mistake. 5. If the student wants different facts, write the scenario as JSON, then run the script so it validates the facts and computes the statement: python income-statement-practice-generator.py schema # template python income-statement-practice-generator.py solve case.json python income-statement-practice-generator.py solve 2026.json --prior 2025.json If validation fails, the error message tells you what to fix. Fix the facts and run again. Do not present a scenario the script rejected. 6. Stay inside Chapter 6. In scope: classifying items into a multiple-step income statement; gross profit, operating income, income before income taxes, income from continuing operations, net income, other comprehensive income (OCI), comprehensive income, and accumulated OCI; gross profit rate, operating margin, and net profit margin; two-year comparisons; how a misplaced item distorts subtotals; what a statement does and does not show. Out of scope: the test for a discontinued operation and its tax presentation (Chapter 7), revenue recognition (Chapter 8), deferred taxes, earnings per share, and cash-flow analysis. If the student asks about those, say they come later and stay with Chapter 6. 7. Use the course's words. Say "income before income taxes" (or "pretax income"), "income from continuing operations", "gross profit rate" (or "gross margin"), "operating margin", and "net profit margin". Say "other comprehensive income (OCI)" on first use. ===================================================================== WHAT THIS SCRIPT GUARANTEES AND WHAT IT DOES NOT DECIDE ===================================================================== The script guarantees the arithmetic and the statement form. Each scenario is validated before use, and each subtotal is recomputed from the supplied lines in this order: Revenue (customer sales only) - Cost of revenue = Gross profit - Operating expenses = Operating income + Nonoperating items (gains, investment income, interest expense) = Income before income taxes - Income tax expense = Income from continuing operations + Discontinued operations, net of tax (only when supplied) = Net income + Other comprehensive income, net of tax (only when supplied) = Comprehensive income Beginning accumulated OCI + OCI for the year = Ending accumulated OCI The script does not make accounting judgments. Each scenario supplies them: which items are operating, whether a disposal qualifies as a discontinued operation, which gains enter OCI, and the income tax basis. Chapter 6 works the same way. A question can test whether the student applies a supplied judgment correctly. It cannot test a judgment the course has not taught yet. Rules the validator enforces, and why: - Revenue, cost of revenue, and operating expenses are entered as positive amounts. Nonoperating items, discontinued operations, and OCI are entered with their sign: gains and income positive, expenses and losses negative. Mixed sign conventions are the most common source of wrong subtotals. - No line may be named like a subtotal ("gross profit", "total", and so on). A subtotal entered as a line would be counted twice. - A scenario supplies income tax expense as an amount, or it states a tax rate as a simplifying assumption. Never both. Chapter 6: do not multiply pretax accounting income by a rate unless the problem supplies that assumption. When a rate is used, the tax must come out to a whole amount in the stated unit. - A peripheral gain is proceeds less the asset's carrying amount. If a scenario states the proceeds and carrying amount, the gain line must equal their difference. The proceeds are never revenue. - Every profit margin uses revenue from the same period. Rates are rounded to one decimal place, half up, as in the reading. - A two-year comparison needs the same company, the same unit, and two different years. Change means current year less prior year. - Amounts are whole numbers in the stated unit, either "USD" or "USD thousands". The period is a year ended December 31. Interpretation rules the questions apply, from Chapter 6: - Moving an amount to another line does not change the amount or net income. It can change gross profit or operating income. - Income from continuing operations can include a gain that may not happen again. "Continuing" is not "recurring". - A matching subtotal label does not prove that two companies include the same costs. Check the lines and notes. - A rise in reported income measures a change. It does not identify the cause (prices, volume, mix, or costs), and it is not cash available. - OCI is a route set by the accounting rule for an item, not a bucket for unrealized or noncash amounts. ===================================================================== COMMANDS ===================================================================== self-test Recompute the course examples and check the generator. Prints PASS or the first failure. generate [options] Print practice questions. --seed N Reproducible set (default 1). --count N Number of questions (default 3). --skill S subtotal | margin | misplacement | comparison | interpretation | oci | mixed (default mixed). --kind K fill-blank | mcq | mixed (default mixed). --output O markdown (default) | json. --unit U USD | "USD thousands" | mixed (default mixed). example NAME [--prior P] Print a course case with every step. Names: sable-ridge-2026, sable-ridge-2025, northline-2026, northline-2025. Use --list. solve FILE [--prior FILE] Validate a JSON scenario and print the worked statement, margins, and optional comparison. --case NAME With the course's Unit 2 case file (unit-2-income-statement-cases.yaml), solve one named case; add --prior-case NAME to compare. schema Print a JSON template and field guide for a scenario you write yourself. Version 2026-09-13. Written for ACC 300, Fall 2026, Chapter 6. """ from __future__ import annotations import argparse import json import random import sys from dataclasses import dataclass, field from decimal import ROUND_HALF_UP, Decimal from pathlib import Path from typing import Any VERSION = "2026-09-13" UNITS = ("USD", "USD thousands") SKILLS = ("subtotal", "margin", "misplacement", "comparison", "interpretation", "oci") KINDS = ("fill-blank", "mcq") FILL = "fill-in-the-blank" MCQ = "multiple-choice" ANSWER_MARKER = "" # A line named like one of these would be a subtotal entered as an item. SUBTOTAL_WORDS = ( "gross profit", "operating income", "income before income taxes", "pretax income", "income from continuing operations", "net income", "comprehensive income", "total", ) MINUS = "−" # the reading writes arithmetic with a true minus sign class CaseError(ValueError): """A scenario broke a course rule. The message says which one.""" # --------------------------------------------------------------------------- # Scenario model # --------------------------------------------------------------------------- def _whole(name: str, value: Any) -> int: """Reject anything that is not a plain whole number.""" if isinstance(value, bool) or not isinstance(value, int): raise CaseError(f"{name} must be a whole number in the stated unit, not {value!r}") return value def _lines(name: str, value: Any, *, positive: bool) -> dict[str, int]: if not isinstance(value, dict): raise CaseError(f"{name} must be a mapping of line label to amount") result: dict[str, int] = {} for label, amount in value.items(): if not isinstance(label, str) or not label.strip(): raise CaseError(f"{name} has a line without a label") lowered = label.strip().lower() if any(word in lowered for word in SUBTOTAL_WORDS): raise CaseError( f"{name} line {label!r} is named like a subtotal; enter the items, " "not a subtotal, or it would be counted twice" ) amount = _whole(f"{name} line {label!r}", amount) if positive and amount < 0: raise CaseError( f"{name} line {label!r} must be a positive amount; revenue, cost of " "revenue, and operating expenses are entered without a sign" ) result[label.strip()] = amount return result @dataclass(frozen=True) class PeripheralSale: """Facts behind a gain or loss on selling an asset the business used.""" proceeds: int carrying_amount: int def __post_init__(self) -> None: _whole("proceeds", self.proceeds) _whole("carrying_amount", self.carrying_amount) if self.proceeds < 0 or self.carrying_amount < 0: raise CaseError("proceeds and carrying amount cannot be negative") @property def gain(self) -> int: return self.proceeds - self.carrying_amount @dataclass(frozen=True) class IncomeStatementCase: """One company, one year, and the supplied lines and judgments. Amount conventions (see the module docstring): revenue, cost_of_revenue, operating_expenses positive amounts other_items, discontinued_after_tax, oci_after_tax signed amounts Supply income_tax_expense or tax_rate, never both. """ entity: str year: int unit: str revenue: dict[str, int] cost_of_revenue: dict[str, int] operating_expenses: dict[str, int] other_items: dict[str, int] = field(default_factory=dict) income_tax_expense: int | None = None tax_rate: Decimal | None = None discontinued_after_tax: int = 0 oci_after_tax: dict[str, int] = field(default_factory=dict) aoci_beginning: int | None = None peripheral_sales: dict[str, PeripheralSale] = field(default_factory=dict) assumptions: tuple[str, ...] = () def __post_init__(self) -> None: if not isinstance(self.entity, str) or len(self.entity.strip()) < 3: raise CaseError("entity needs a company name") _whole("year", self.year) if not 2000 <= self.year <= 2099: raise CaseError("year must be a four-digit year between 2000 and 2099") if self.unit not in UNITS: raise CaseError(f"unit must be one of {UNITS}") object.__setattr__(self, "revenue", _lines("revenue", self.revenue, positive=True)) object.__setattr__( self, "cost_of_revenue", _lines("cost_of_revenue", self.cost_of_revenue, positive=True) ) object.__setattr__( self, "operating_expenses", _lines("operating_expenses", self.operating_expenses, positive=True), ) object.__setattr__( self, "other_items", _lines("other_items", self.other_items, positive=False) ) object.__setattr__( self, "oci_after_tax", _lines("oci_after_tax", self.oci_after_tax, positive=False) ) if not self.revenue: raise CaseError("revenue needs at least one customer sales line") if not self.cost_of_revenue: raise CaseError("cost_of_revenue needs at least one line") if not self.operating_expenses: raise CaseError("operating_expenses needs at least one line") # A revenue line and its cost line may share a name ("Consumables" sold # and "Consumables" cost). Every other label must be unique, so a moved # line cannot collide with an existing one. below_gross = [*self.operating_expenses, *self.other_items, *self.oci_after_tax] if len(set(below_gross)) != len(below_gross): raise CaseError("a line label may appear only once below gross profit") if set(below_gross) & (set(self.revenue) | set(self.cost_of_revenue)): raise CaseError("a revenue or cost line label may not reappear below gross profit") _whole("discontinued_after_tax", self.discontinued_after_tax) if self.income_tax_expense is not None: _whole("income_tax_expense", self.income_tax_expense) if self.income_tax_expense < 0: raise CaseError("supplied income tax expense cannot be negative") if self.tax_rate is not None: if isinstance(self.tax_rate, bool): raise CaseError("tax_rate must be a number such as 0.25") object.__setattr__(self, "tax_rate", Decimal(str(self.tax_rate))) assert self.tax_rate is not None if not Decimal(0) <= self.tax_rate <= Decimal(1): raise CaseError("tax_rate must be between 0 and 1, such as 0.25 for 25 percent") if (self.income_tax_expense is None) == (self.tax_rate is None): raise CaseError( "supply income_tax_expense as an amount, or state tax_rate as a simplifying " "assumption, but not both; Chapter 6 does not compute tax from pretax income " "unless the problem supplies that assumption" ) if self.aoci_beginning is not None: _whole("aoci_beginning", self.aoci_beginning) if not isinstance(self.peripheral_sales, dict): raise CaseError("peripheral_sales must map a nonoperating line label to its facts") sales: dict[str, PeripheralSale] = {} for label, facts in self.peripheral_sales.items(): if isinstance(facts, dict): facts = PeripheralSale(**facts) if not isinstance(facts, PeripheralSale): raise CaseError(f"peripheral_sales[{label!r}] needs proceeds and carrying_amount") if label not in self.other_items: raise CaseError( f"peripheral_sales[{label!r}] must name a line in other_items; a gain on " "an asset the business used is a nonoperating item, not revenue" ) if self.other_items[label] != facts.gain: raise CaseError( f"{label!r} must equal proceeds {facts.proceeds:,} less carrying amount " f"{facts.carrying_amount:,} = {facts.gain:,}; the gain is not the proceeds" ) sales[label] = facts object.__setattr__(self, "peripheral_sales", sales) if isinstance(self.assumptions, str): raise CaseError("assumptions must be a list of sentences") object.__setattr__(self, "assumptions", tuple(str(item) for item in self.assumptions)) # Chapter 6 works with profitable continuing operations; loss years need # tax benefits and other topics the chapter does not teach. pretax = self._pretax() if self.tax_rate is not None: if pretax < 0: raise CaseError( "a tax rate assumption needs positive income before income taxes; " "supply income_tax_expense instead for a loss year" ) tax = Decimal(pretax) * self.tax_rate if tax != tax.to_integral_value(): raise CaseError( f"income tax at {self.tax_rate} on {num(pretax)} is not a whole {self.unit} " "amount; change an amount or supply income_tax_expense" ) def _pretax(self) -> int: return ( sum(self.revenue.values()) - sum(self.cost_of_revenue.values()) - sum(self.operating_expenses.values()) + sum(self.other_items.values()) ) @property def period(self) -> str: return f"Year ended December 31, {self.year}" @property def period_phrase(self) -> str: """The period in the middle of a sentence.""" return f"year ended December 31, {self.year}" @classmethod def from_dict(cls, data: Any) -> IncomeStatementCase: if not isinstance(data, dict): raise CaseError("a scenario must be a JSON object") known = { "entity", "year", "unit", "revenue", "cost_of_revenue", "operating_expenses", "other_items", "income_tax_expense", "tax_rate", "discontinued_after_tax", "oci_after_tax", "aoci_beginning", "peripheral_sales", "assumptions", } unknown = sorted(set(data) - known) if unknown: raise CaseError(f"unknown scenario fields: {', '.join(unknown)}; run `schema`") missing = sorted( {"entity", "year", "unit", "revenue", "cost_of_revenue", "operating_expenses"} - set(data) ) if missing: raise CaseError(f"missing scenario fields: {', '.join(missing)}; run `schema`") return cls(**data) def to_dict(self) -> dict[str, Any]: data: dict[str, Any] = { "entity": self.entity, "year": self.year, "unit": self.unit, "revenue": dict(self.revenue), "cost_of_revenue": dict(self.cost_of_revenue), "operating_expenses": dict(self.operating_expenses), "other_items": dict(self.other_items), } if self.income_tax_expense is not None: data["income_tax_expense"] = self.income_tax_expense if self.tax_rate is not None: data["tax_rate"] = str(self.tax_rate) if self.discontinued_after_tax: data["discontinued_after_tax"] = self.discontinued_after_tax if self.oci_after_tax: data["oci_after_tax"] = dict(self.oci_after_tax) if self.aoci_beginning is not None: data["aoci_beginning"] = self.aoci_beginning if self.peripheral_sales: data["peripheral_sales"] = { label: {"proceeds": s.proceeds, "carrying_amount": s.carrying_amount} for label, s in self.peripheral_sales.items() } data["assumptions"] = list(self.assumptions) return data def replace(self, **changes: Any) -> IncomeStatementCase: data = self.to_dict() data.update(changes) return IncomeStatementCase.from_dict(data) # --------------------------------------------------------------------------- # Calculation # --------------------------------------------------------------------------- @dataclass(frozen=True) class Step: label: str working: str amount: int @dataclass(frozen=True) class IncomeStatementResult: revenue: int cost_of_revenue: int gross_profit: int operating_expenses: int operating_income: int other_items: int pretax_income: int income_tax: int tax_basis: str continuing_income: int discontinued_after_tax: int net_income: int oci_after_tax: int comprehensive_income: int aoci_ending: int | None gross_profit_rate: Decimal operating_margin: Decimal net_profit_margin: Decimal steps: tuple[Step, ...] def subtotal(self, name: str) -> int: return int(getattr(self, name)) SUBTOTAL_NAMES = { "gross_profit": "Gross profit", "operating_income": "Operating income", "pretax_income": "Income before income taxes", "continuing_income": "Income from continuing operations", "net_income": "Net income", "comprehensive_income": "Comprehensive income", } MARGIN_NAMES = { "gross_profit_rate": ("Gross profit rate (gross margin)", "gross_profit"), "operating_margin": ("Operating margin", "operating_income"), "net_profit_margin": ("Net profit margin", "net_income"), } def fmt(amount: int) -> str: """Statement style: 1,000 or (1,000).""" return f"({abs(amount):,})" if amount < 0 else f"{amount:,}" def signed(amount: int) -> str: """Arithmetic style: + 1,000 or − 1,000.""" return f"{'+' if amount >= 0 else MINUS} {abs(amount):,}" def num(amount: int) -> str: """A result: 1,000 or −1,000.""" return f"{MINUS}{abs(amount):,}" if amount < 0 else f"{amount:,}" def pts(value: Decimal) -> str: """A change in percentage points: +5.4 or −2.3.""" return f"{'+' if value >= 0 else MINUS}{abs(value)}" def operand(amount: int) -> str: """An amount inside a subtraction: 1,000 or (−1,000).""" return f"({MINUS}{abs(amount):,})" if amount < 0 else f"{amount:,}" def lc(label: str) -> str: """Lower the first letter only, so a line label fits mid-sentence and keeps its names.""" return label[:1].lower() + label[1:] def join_words(items: tuple[str, ...] | list[str]) -> str: """a; a and b; a, b, and c.""" words = [lc(item) for item in items] if len(words) <= 1: return "".join(words) if len(words) == 2: return f"{words[0]} and {words[1]}" return ", ".join(words[:-1]) + f", and {words[-1]}" def money(amount: int, unit: str) -> str: """Prose style: $7,000,000 or $9,000 thousand, with a minus sign if negative.""" body = f"${abs(amount):,}" if unit == "USD thousands": body += " thousand" return f"{MINUS}{body}" if amount < 0 else body def percent(numerator: int, denominator: int) -> Decimal: """Share of revenue rounded to one decimal place, half up, as in the reading.""" if denominator == 0: raise CaseError("a profit margin needs revenue greater than zero") return (Decimal(numerator) * 100 / Decimal(denominator)).quantize( Decimal("0.1"), rounding=ROUND_HALF_UP ) def pct(value: Decimal) -> str: return f"{value}%" def _sum_working(lines: dict[str, int], *, negate: bool = False) -> str: parts = [] for label, amount in lines.items(): value = -amount if negate else amount parts.append(f"{label} {signed(value)}") return "; ".join(parts) def calculate(case: IncomeStatementCase) -> IncomeStatementResult: """Recompute every subtotal in the course order and record each step.""" steps: list[Step] = [] revenue = sum(case.revenue.values()) if len(case.revenue) > 1: steps.append( Step("Revenue", "Add the customer sales lines: " + _sum_working(case.revenue), revenue) ) else: steps.append(Step("Revenue", f"Customer sales only: {num(revenue)}", revenue)) cost = sum(case.cost_of_revenue.values()) gross = revenue - cost steps.append( Step( "Gross profit", f"Revenue {num(revenue)} {MINUS} cost of revenue {num(cost)}", gross, ) ) expenses = sum(case.operating_expenses.values()) operating = gross - expenses steps.append( Step( "Operating income", f"Gross profit {num(gross)}; " + _sum_working(case.operating_expenses, negate=True), operating, ) ) other = sum(case.other_items.values()) pretax = operating + other if case.other_items: steps.append( Step( "Income before income taxes", f"Operating income {num(operating)}; " + _sum_working(case.other_items), pretax, ) ) else: steps.append( Step( "Income before income taxes", "No nonoperating items, so it equals operating income", pretax, ) ) if case.income_tax_expense is not None: tax = case.income_tax_expense basis = "supplied" steps.append( Step( "Income tax expense", "Supplied by the problem; it is not computed from pretax income", tax, ) ) else: assert case.tax_rate is not None tax = int(Decimal(pretax) * case.tax_rate) basis = "rate" rate = (case.tax_rate * 100).normalize() steps.append( Step( "Income tax expense", f"Stated assumption: {rate:f}% of income before income taxes {num(pretax)}", tax, ) ) continuing = pretax - tax steps.append( Step( "Income from continuing operations", f"Income before income taxes {num(pretax)} {MINUS} income tax expense {num(tax)}", continuing, ) ) net = continuing + case.discontinued_after_tax if case.discontinued_after_tax: steps.append( Step( "Net income", f"Income from continuing operations {num(continuing)}; discontinued operations, " f"net of tax {signed(case.discontinued_after_tax)}", net, ) ) else: steps.append( Step( "Net income", "No discontinued operation, so it equals income from continuing operations", net, ) ) oci = sum(case.oci_after_tax.values()) comprehensive = net + oci aoci_ending = None if case.oci_after_tax: steps.append(Step("Other comprehensive income", _sum_working(case.oci_after_tax), oci)) steps.append( Step("Comprehensive income", f"Net income {num(net)} {signed(oci)} OCI", comprehensive) ) if case.aoci_beginning is not None: aoci_ending = case.aoci_beginning + oci steps.append( Step( "Ending accumulated OCI", f"Beginning AOCI {num(case.aoci_beginning)} {signed(oci)} OCI for the year", aoci_ending, ) ) return IncomeStatementResult( revenue=revenue, cost_of_revenue=cost, gross_profit=gross, operating_expenses=expenses, operating_income=operating, other_items=other, pretax_income=pretax, income_tax=tax, tax_basis=basis, continuing_income=continuing, discontinued_after_tax=case.discontinued_after_tax, net_income=net, oci_after_tax=oci, comprehensive_income=comprehensive, aoci_ending=aoci_ending, gross_profit_rate=percent(gross, revenue), operating_margin=percent(operating, revenue), net_profit_margin=percent(net, revenue), steps=tuple(steps), ) def margin_step(case: IncomeStatementCase, result: IncomeStatementResult, name: str) -> str: title, numerator = MARGIN_NAMES[name] value = result.subtotal(numerator) return ( f"{title} = {SUBTOTAL_NAMES[numerator]} {value:,} ÷ revenue {result.revenue:,} " f"= {pct(getattr(result, name))} for {case.year}" ) @dataclass(frozen=True) class Comparison: prior: IncomeStatementCase current: IncomeStatementCase prior_result: IncomeStatementResult current_result: IncomeStatementResult changes: dict[str, int] margin_changes: dict[str, Decimal] def compare(prior: IncomeStatementCase, current: IncomeStatementCase) -> Comparison: """Current year less prior year, for the same company in the same unit.""" if prior.entity != current.entity: raise CaseError("a comparison needs the same company in both years") if prior.unit != current.unit: raise CaseError("a comparison needs the same unit in both years") if prior.year >= current.year: raise CaseError("the prior year must come before the current year") before = calculate(prior) after = calculate(current) changes = {"revenue": after.revenue - before.revenue} for name in SUBTOTAL_NAMES: changes[name] = after.subtotal(name) - before.subtotal(name) margin_changes = {name: getattr(after, name) - getattr(before, name) for name in MARGIN_NAMES} return Comparison(prior, current, before, after, changes, margin_changes) # --------------------------------------------------------------------------- # Rendering the statement # --------------------------------------------------------------------------- def statement_table(case: IncomeStatementCase, result: IncomeStatementResult) -> str: rows: list[str] = [ f"**{case.entity}** · Income Statement · {case.period} · {case.unit}", "", "| Line | Amount |", "|---|---:|", ] for label, amount in case.revenue.items(): rows.append(f"| {label} | {fmt(amount)} |") if len(case.revenue) > 1: rows.append(f"| **Revenue** | **{fmt(result.revenue)}** |") for label, amount in case.cost_of_revenue.items(): rows.append(f"| {label} | {fmt(-amount)} |") rows.append(f"| **Gross profit** | **{fmt(result.gross_profit)}** |") for label, amount in case.operating_expenses.items(): rows.append(f"| {label} | {fmt(-amount)} |") rows.append(f"| **Operating income** | **{fmt(result.operating_income)}** |") for label, amount in case.other_items.items(): rows.append(f"| {label} | {fmt(amount)} |") rows.append(f"| **Income before income taxes** | **{fmt(result.pretax_income)}** |") rows.append(f"| Income tax expense | {fmt(-result.income_tax)} |") if case.discontinued_after_tax: rows.append( f"| **Income from continuing operations** | **{fmt(result.continuing_income)}** |" ) word = "Income" if case.discontinued_after_tax > 0 else "Loss" rows.append( f"| {word} from discontinued operations, net of tax | {fmt(case.discontinued_after_tax)} |" ) rows.append(f"| **Net income** | **{fmt(result.net_income)}** |") if case.oci_after_tax: for label, amount in case.oci_after_tax.items(): rows.append(f"| {label} | {fmt(amount)} |") rows.append(f"| **Other comprehensive income** | **{fmt(result.oci_after_tax)}** |") rows.append(f"| **Comprehensive income** | **{fmt(result.comprehensive_income)}** |") return "\n".join(rows) def margins_table(case: IncomeStatementCase, result: IncomeStatementResult) -> str: rows = ["| Rate | Calculation |", "|---|---:|"] for name, (title, numerator) in MARGIN_NAMES.items(): rows.append( f"| {title} | {result.subtotal(numerator):,} ÷ {result.revenue:,} = " f"**{pct(getattr(result, name))}** |" ) return "\n".join(rows) def steps_list(steps: tuple[Step, ...] | list[str]) -> str: lines = [] for index, step in enumerate(steps, start=1): if isinstance(step, Step): lines.append(f"{index}. {step.label}: {step.working} = **{num(step.amount)}**") else: lines.append(f"{index}. {step}") return "\n".join(lines) def worked_case(case: IncomeStatementCase, prior: IncomeStatementCase | None = None) -> str: result = calculate(case) parts = [statement_table(case, result), "", "**Steps**", "", steps_list(result.steps), ""] parts += ["**Profit margins**", "", margins_table(case, result)] if case.assumptions: parts += ["", "**Supplied judgments and assumptions**", ""] parts += [f"- {item}" for item in case.assumptions] if prior is not None: comparison = compare(prior, case) parts += ["", comparison_table(comparison)] return "\n".join(parts) def comparison_table(comparison: Comparison) -> str: before, after = comparison.prior_result, comparison.current_result rows = [ f"**{comparison.current.entity}** · {comparison.prior.year} and {comparison.current.year} · " f"{comparison.current.unit} · Change = {comparison.current.year} less {comparison.prior.year}", "", f"| Subtotal | {comparison.prior.year} | {comparison.current.year} | Change |", "|---|---:|---:|---:|", f"| Revenue | {fmt(before.revenue)} | {fmt(after.revenue)} | {fmt(comparison.changes['revenue'])} |", ] for name, title in SUBTOTAL_NAMES.items(): if name == "comprehensive_income" and not ( comparison.prior.oci_after_tax or comparison.current.oci_after_tax ): continue if name == "continuing_income" and not ( comparison.prior.discontinued_after_tax or comparison.current.discontinued_after_tax ): continue rows.append( f"| {title} | {fmt(before.subtotal(name))} | {fmt(after.subtotal(name))} | " f"{fmt(comparison.changes[name])} |" ) rows += [ "", "| Rate | " + f"{comparison.prior.year} | {comparison.current.year} | Change, points |", "|---|---:|---:|---:|", ] for name, (title, _) in MARGIN_NAMES.items(): rows.append( f"| {title} | {pct(getattr(before, name))} | {pct(getattr(after, name))} | " f"{pts(comparison.margin_changes[name])} |" ) return "\n".join(rows) # --------------------------------------------------------------------------- # Course cases from Chapter 6, used by `example` and the self-test # --------------------------------------------------------------------------- COURSE_CASES: dict[str, IncomeStatementCase] = { "sable-ridge-2026": IncomeStatementCase( entity="Sable Ridge Instruments", year=2026, unit="USD", revenue={ "Instrument sales": 4_200_000, "Consumables sales": 1_850_000, "Installation and calibration revenue": 950_000, }, cost_of_revenue={"Cost of revenue": 3_800_000}, operating_expenses={ "Selling and marketing": 980_000, "Research and development": 620_000, "General and administrative": 645_000, "Loss on uncollectible supplier advance": 95_000, }, other_items={ "Gain on sale of the Dayton warehouse": 180_000, "Realized gain on sale of investments": 40_000, "Interest and dividend income": 60_000, "Interest expense": -140_000, }, income_tax_expense=250_000, discontinued_after_tax=-150_000, oci_after_tax={ "Unrealized holding gain on available-for-sale debt securities, net of tax": 60_000, "Reclassification adjustment, net of tax": -30_000, }, aoci_beginning=45_000, assumptions=( "The veterinary-line loss is reported as a discontinued operation; Chapter 7 teaches the test.", "The supplier-advance loss belongs in operating expenses, as the controller determined.", "The securities are classified as available for sale; the after-tax OCI amounts are supplied.", ), ), "sable-ridge-2025": IncomeStatementCase( entity="Sable Ridge Instruments", year=2025, unit="USD", revenue={ "Instrument sales": 3_900_000, "Consumables sales": 1_650_000, "Installation and calibration revenue": 850_000, }, cost_of_revenue={"Cost of revenue": 3_520_000}, operating_expenses={ "Selling and marketing": 900_000, "Research and development": 580_000, "General and administrative": 700_000, }, other_items={"Interest and dividend income": 55_000, "Interest expense": -155_000}, income_tax_expense=150_000, discontinued_after_tax=135_000, assumptions=( "The 2025 statement is recast so the veterinary line is discontinued in both years.", ), ), "northline-2026": IncomeStatementCase( entity="Northline Components", year=2026, unit="USD thousands", revenue={"Net sales": 9_000}, cost_of_revenue={"Cost of goods sold": 5_400}, operating_expenses={"Selling expenses": 1_500, "Administrative expenses": 1_100}, other_items={"Gain on sale of unused land": 200, "Interest expense": -300}, income_tax_expense=225, assumptions=( "The land sale is outside ordinary sales.", "There is no discontinued operation.", ), ), "northline-2025": IncomeStatementCase( entity="Northline Components", year=2025, unit="USD thousands", revenue={"Net sales": 8_400}, cost_of_revenue={"Cost of goods sold": 5_040}, operating_expenses={"Selling expenses": 1_400, "Administrative expenses": 1_100}, other_items={"Interest expense": -260}, income_tax_expense=150, assumptions=("There was no land-sale gain or discontinued operation in 2025.",), ), } COURSE_PRIORS = {"sable-ridge-2026": "sable-ridge-2025", "northline-2026": "northline-2025"} # --------------------------------------------------------------------------- # Questions # --------------------------------------------------------------------------- @dataclass(frozen=True) class Choice: key: str text: str correct: bool rationale: str @dataclass(frozen=True) class Question: id: str skill: str format: str title: str facts: str prompt: str choices: tuple[Choice, ...] answer: str tolerance: str steps: tuple[str, ...] rule: str source: str def to_dict(self) -> dict[str, Any]: return { "id": self.id, "skill": self.skill, "format": self.format, "title": self.title, "facts_markdown": self.facts, "prompt": self.prompt, "choices": [ {"key": c.key, "text": c.text, "correct": c.correct, "rationale": c.rationale} for c in self.choices ], "answer": self.answer, "tolerance": self.tolerance, "steps": list(self.steps), "rule": self.rule, "source": self.source, } def render_markdown(question: Question) -> str: parts = [ f"## {question.title}", "", f"Question `{question.id}` · Skill: {question.skill} · Format: {question.format}", "", question.facts, "", "**Question**", "", question.prompt, ] if question.choices: parts.append("") parts += [f"- ({c.key}) {c.text}" for c in question.choices] parts += [ "", ANSWER_MARKER, "", f"**Answer:** {question.answer}", "", f"Tolerance: {question.tolerance}", "", ] parts += ["**Steps**", "", steps_list(list(question.steps))] wrong = [c for c in question.choices if not c.correct] if wrong: parts += ["", "**Why the other choices are wrong**", ""] parts += [f"- ({c.key}) {c.rationale}" for c in wrong] parts += ["", f"**Class rule:** {question.rule}", "", f"Source: {question.source}"] return "\n".join(parts) def facts_block(case: IncomeStatementCase, rng: random.Random, *, scramble: bool = True) -> str: """The adjusted amounts, not in statement order, plus the supplied facts.""" rows: list[tuple[str, int]] = [] rows += [(label, amount) for label, amount in case.revenue.items()] rows += [(label, -amount) for label, amount in case.cost_of_revenue.items()] rows += [(label, -amount) for label, amount in case.operating_expenses.items()] rows += [(label, amount) for label, amount in case.other_items.items()] if case.income_tax_expense is not None: rows.append(("Income tax expense", -case.income_tax_expense)) if case.discontinued_after_tax: word = "Income" if case.discontinued_after_tax > 0 else "Loss" rows.append( (f"{word} from discontinued operations, net of tax", case.discontinued_after_tax) ) if scramble: rng.shuffle(rows) lines = [ f"**{case.entity}**, adjusted amounts for the {case.period_phrase}, in {case.unit}. " "The amounts are not in statement order. Expenses and losses are shown in parentheses.", "", "| Adjusted amount | Amount |", "|---|---:|", ] lines += [f"| {label} | {fmt(amount)} |" for label, amount in rows] notes = list(case.assumptions) if case.tax_rate is not None: rate = (case.tax_rate * 100).normalize() notes.insert( 0, f"Simplifying assumption: income tax expense equals {rate:f}% of income before income taxes.", ) for label, sale in case.peripheral_sales.items(): notes.append( f"{label}: proceeds of {money(sale.proceeds, case.unit)} less a carrying amount of " f"{money(sale.carrying_amount, case.unit)}. The proceeds are not revenue." ) if notes: lines += ["", "Supplied facts:", ""] + [f"- {note}" for note in notes] return "\n".join(lines) def subtotal_block(case: IncomeStatementCase, result: IncomeStatementResult) -> str: """Completed subtotals only, for questions that start after the statement is built.""" lines = [ f"**{case.entity}**, {case.period_phrase}, in {case.unit}. The statement is complete.", "", "| Subtotal | Amount |", "|---|---:|", f"| Revenue | {fmt(result.revenue)} |", f"| Gross profit | {fmt(result.gross_profit)} |", f"| Operating income | {fmt(result.operating_income)} |", f"| Income before income taxes | {fmt(result.pretax_income)} |", ] if case.discontinued_after_tax: lines.append(f"| Income from continuing operations | {fmt(result.continuing_income)} |") lines.append(f"| Net income | {fmt(result.net_income)} |") return "\n".join(lines) def _letters(choices: list[Choice], rng: random.Random) -> tuple[Choice, ...]: rng.shuffle(choices) keys = "abcd" return tuple( Choice(keys[index], choice.text, choice.correct, choice.rationale) for index, choice in enumerate(choices) ) def _amount_choices( rng: random.Random, unit: str, correct: int, correct_rationale: str, candidates: list[tuple[int, str]], fallback_lines: dict[str, int], ) -> tuple[Choice, ...]: """Four distinct amounts: the correct one and three specific mistakes.""" seen = {correct} wrong: list[tuple[int, str]] = [] for value, rationale in candidates: if value not in seen: seen.add(value) wrong.append((value, rationale)) if len(wrong) == 3: break for label, amount in sorted(fallback_lines.items(), key=lambda item: -abs(item[1])): if len(wrong) == 3: break for value, rationale in ( (correct + abs(amount), f"This double counts {label} by adding it back."), (correct - abs(amount), f"This deducts {label} a second time."), ): if value not in seen and len(wrong) < 3: seen.add(value) wrong.append((value, rationale)) if len(wrong) < 3: raise CaseError("could not build three distinct wrong choices for this scenario") choices = [Choice("", money(correct, unit), True, correct_rationale)] choices += [Choice("", money(value, unit), False, rationale) for value, rationale in wrong] return _letters(choices, rng) def _positive_other(case: IncomeStatementCase) -> tuple[str, int] | None: gains = [(label, amount) for label, amount in case.other_items.items() if amount > 0] if not gains: return None return max(gains, key=lambda item: item[1]) def _negative_other(case: IncomeStatementCase) -> tuple[str, int] | None: costs = [(label, amount) for label, amount in case.other_items.items() if amount < 0] if not costs: return None return min(costs, key=lambda item: item[1]) def _largest_opex(case: IncomeStatementCase) -> tuple[str, int]: return max(case.operating_expenses.items(), key=lambda item: item[1]) def _steps_through(result: IncomeStatementResult, label: str) -> tuple[str, ...]: """The course steps up to and including the named subtotal.""" out: list[str] = [] for step in result.steps: out.append(f"{step.label}: {step.working} = {num(step.amount)}") if step.label == label: break return tuple(out) SOURCE_SUBTOTALS = "Chapter 6, Steps 1 to 4, building the multiple-step income statement." SOURCE_DISTORTION = "Chapter 6, how classification errors distort income-statement subtotals." SOURCE_COMPARE = ( "Chapter 6, compare income statements across years, and limits of income-statement comparisons." ) SOURCE_OCI = "Chapter 6, other comprehensive income and accumulated OCI." def question_subtotal( case: IncomeStatementCase, rng: random.Random, kind: str, qid: str ) -> Question: result = calculate(case) targets = ["gross_profit", "operating_income", "pretax_income", "net_income"] if case.discontinued_after_tax: targets.append("continuing_income") if case.oci_after_tax: targets.append("comprehensive_income") target = rng.choice(targets) title = SUBTOTAL_NAMES[target] correct = result.subtotal(target) facts = facts_block(case, rng) if case.oci_after_tax: facts += ( "\n\nOther comprehensive income, net of tax: " + "; ".join( f"{label} {money(amount, case.unit)}" for label, amount in case.oci_after_tax.items() ) + "." ) prompt = f"What is the {case.year} {lc(title)} of {case.entity}?" steps = _steps_through(result, title) rule = { "gross_profit": "Gross profit is customer revenue less the cost of the goods and services sold. A gain on an asset the business used is not revenue.", "operating_income": "Operating income deducts the operating expenses from gross profit. Nonoperating items and income tax come later.", "pretax_income": "Income before income taxes adds the nonoperating gains and income and deducts the nonoperating expenses. A gain is proceeds less carrying amount, never the proceeds.", "continuing_income": "Income from continuing operations is the after-tax result of the activities the company continues. The discontinued result comes after it.", "net_income": "Net income is income from continuing operations plus or minus the discontinued result, net of tax. Classification does not change it.", "comprehensive_income": "Comprehensive income is net income plus other comprehensive income for the period. Accumulated OCI is a balance, not a period amount.", }[target] if kind == "fill-blank": return Question( qid, "subtotal", FILL, f"Calculate {lc(title)}", facts, prompt + " ________", (), money(correct, case.unit), "Exact amount in the stated unit.", steps, rule, SOURCE_SUBTOTALS, ) gain = _positive_other(case) cost = _negative_other(case) opex_label, opex_amount = _largest_opex(case) candidates: list[tuple[int, str]] = [] if target == "gross_profit": candidates.append( ( result.operating_income, "This is operating income; it also deducts the operating expenses.", ) ) if gain: candidates.append( ( result.gross_profit + gain[1], f"This adds {lc(gain[0])} to customer revenue. That gain is not revenue from customers.", ) ) candidates.append( ( result.gross_profit - opex_amount, f"This deducts {lc(opex_label)}, which belongs below gross profit.", ) ) candidates.append((result.revenue, "This is revenue before deducting the cost of revenue.")) elif target == "operating_income": candidates.append( ( result.gross_profit, "This is gross profit; the operating expenses have not been deducted.", ) ) candidates.append( ( result.pretax_income, "This is income before income taxes; it includes the nonoperating items.", ) ) candidates.append( (result.operating_income + opex_amount, f"This leaves out {lc(opex_label)}.") ) if gain: candidates.append( ( result.operating_income + gain[1], f"This adds {lc(gain[0])} above operating income; it belongs below.", ) ) elif target == "pretax_income": candidates.append( ( result.operating_income, "This is operating income; the nonoperating items are not yet included.", ) ) if cost: candidates.append((result.pretax_income - cost[1], f"This leaves out {lc(cost[0])}.")) candidates.append( ( result.continuing_income, "This is income from continuing operations, after income tax.", ) ) for label, sale in case.peripheral_sales.items(): candidates.append( ( result.pretax_income + sale.carrying_amount, f"This adds the {money(sale.proceeds, case.unit)} proceeds instead of the gain for {lc(label)}.", ) ) elif target == "continuing_income": candidates.append( ( result.pretax_income, "This is income before income taxes; income tax has not been deducted.", ) ) candidates.append( (result.net_income, "This is net income; it includes the discontinued result.") ) candidates.append( ( result.pretax_income + result.income_tax, "This adds income tax expense instead of deducting it.", ) ) elif target == "net_income": if case.discontinued_after_tax: candidates.append( ( result.continuing_income, "This is income from continuing operations; the discontinued result is not yet included.", ) ) candidates.append( ( result.continuing_income - case.discontinued_after_tax, "This reverses the sign of the discontinued result.", ) ) candidates.append( ( result.pretax_income, "This is income before income taxes; income tax has not been deducted.", ) ) if case.oci_after_tax: candidates.append( ( result.comprehensive_income, "This is comprehensive income; OCI is not part of net income.", ) ) candidates.append( ( result.operating_income, "This is operating income; nonoperating items and tax are missing.", ) ) else: candidates.append((result.net_income, "This is net income; OCI has not been added.")) candidates.append( (result.net_income - result.oci_after_tax, "This reverses the sign of OCI.") ) if result.aoci_ending is not None: candidates.append( ( result.net_income + result.aoci_ending, "This adds ending accumulated OCI, a cumulative equity balance, instead of the year's OCI.", ) ) candidates.append( ( result.continuing_income + result.oci_after_tax, "This starts from continuing operations and skips the discontinued result.", ) ) fallback = {**case.operating_expenses, **{k: v for k, v in case.other_items.items()}} choices = _amount_choices(rng, case.unit, correct, "Correct.", candidates, fallback) answer = next(c for c in choices if c.correct) return Question( qid, "subtotal", MCQ, f"Calculate {lc(title)}", facts, prompt, choices, f"({answer.key}) {answer.text}", "Exact amount in the stated unit.", steps, rule, SOURCE_SUBTOTALS, ) def question_margin(case: IncomeStatementCase, rng: random.Random, kind: str, qid: str) -> Question: result = calculate(case) name = rng.choice(list(MARGIN_NAMES)) title, numerator = MARGIN_NAMES[name] facts = subtotal_block(case, result) prompt = f"What is the {case.year} {lc(title)} of {case.entity}? Round to one decimal place." correct = getattr(result, name) steps = (margin_step(case, result, name),) rule = ( "A profit margin divides a statement subtotal by revenue for the same period. The numerator names " "what the rate includes: gross profit deducts only cost of revenue, operating income also deducts " "operating expenses, and net income includes every item on the statement." ) if kind == "fill-blank": return Question( qid, "margin", FILL, f"Calculate the {lc(title)}", facts, prompt + " ________", (), pct(correct), "Within 0.1 percentage point.", steps, rule, SOURCE_COMPARE, ) others = { "gross_profit_rate": "This divides gross profit by revenue; it deducts only cost of revenue.", "operating_margin": "This divides operating income by revenue; it also deducts operating expenses.", "net_profit_margin": "This divides net income by revenue; it includes nonoperating items, tax, and any discontinued result.", } seen = {correct} wrong: list[Choice] = [] for other, rationale in others.items(): value = getattr(result, other) if other != name and value not in seen: seen.add(value) wrong.append(Choice("", pct(value), False, rationale)) extra = [ ( percent(result.pretax_income, result.revenue), "This divides income before income taxes by revenue. The course does not use a " "pretax margin here.", ), ( percent(result.cost_of_revenue, result.revenue), "This is cost of revenue as a share of revenue, the complement of the gross " "profit rate.", ), ( percent(result.subtotal(numerator), result.gross_profit), "This divides by gross profit instead of revenue. Every margin in this course " "divides by revenue for the same period.", ), ] for value, rationale in extra: if len(wrong) == 3: break if value not in seen: seen.add(value) wrong.append(Choice("", pct(value), False, rationale)) if len(wrong) < 3: raise CaseError("could not build three distinct wrong margins for this scenario") choices = _letters([Choice("", pct(correct), True, "Correct."), *wrong], rng) answer = next(c for c in choices if c.correct) return Question( qid, "margin", MCQ, f"Calculate the {lc(title)}", facts, prompt, choices, f"({answer.key}) {answer.text}", "Within 0.1 percentage point.", steps, rule, SOURCE_COMPARE, ) def misplaced_draft(case: IncomeStatementCase, pattern: str, label: str) -> IncomeStatementCase: """A draft statement with one item on the wrong line. The amount is unchanged.""" if pattern == "gain-in-revenue": amount = case.other_items[label] if amount <= 0: raise CaseError("gain-in-revenue needs a positive nonoperating item") other = {k: v for k, v in case.other_items.items() if k != label} sales = {k: v for k, v in case.peripheral_sales.items() if k != label} return case.replace( revenue={**case.revenue, label: amount}, other_items=other, peripheral_sales={ k: {"proceeds": s.proceeds, "carrying_amount": s.carrying_amount} for k, s in sales.items() }, ) if pattern == "operating-expense-in-cost-of-revenue": amount = case.operating_expenses[label] if len(case.operating_expenses) < 2: raise CaseError("this draft needs at least two operating expense lines") opex = {k: v for k, v in case.operating_expenses.items() if k != label} return case.replace( cost_of_revenue={**case.cost_of_revenue, label: amount}, operating_expenses=opex ) if pattern == "interest-in-operating-expenses": amount = case.other_items[label] if amount >= 0: raise CaseError("interest-in-operating-expenses needs a nonoperating expense") other = {k: v for k, v in case.other_items.items() if k != label} return case.replace( operating_expenses={**case.operating_expenses, label: -amount}, other_items=other ) raise CaseError(f"unknown misplacement pattern {pattern}") def question_misplacement( case: IncomeStatementCase, rng: random.Random, kind: str, qid: str ) -> Question: result = calculate(case) patterns: list[tuple[str, str]] = [] gain = _positive_other(case) if gain: patterns.append(("gain-in-revenue", gain[0])) if len(case.operating_expenses) >= 2: patterns.append(("operating-expense-in-cost-of-revenue", _largest_opex(case)[0])) cost = _negative_other(case) if cost: patterns.append(("interest-in-operating-expenses", cost[0])) pattern, label = rng.choice(patterns) draft = misplaced_draft(case, pattern, label) draft_result = calculate(draft) amount = abs(case.other_items.get(label, case.operating_expenses.get(label, 0))) facts = facts_block(case, rng) if pattern == "gain-in-revenue": story = f"A draft statement includes the {money(amount, case.unit)} {lc(label)} in customer revenue instead of showing it below operating income." affected = ("Revenue", "Gross profit", "Operating income") direction = "overstates" unchanged = ("Income before income taxes", "Net income") blanks = [ ("revenue", "Revenue"), ("gross_profit", "Gross profit"), ("operating_income", "Operating income"), ("pretax_income", "Income before income taxes"), ("net_income", "Net income"), ] elif pattern == "operating-expense-in-cost-of-revenue": story = f"A draft statement includes the {money(amount, case.unit)} of {lc(label)} in cost of revenue instead of in operating expenses." affected = ("Gross profit",) direction = "understates" unchanged = ("Operating income", "Income before income taxes", "Net income") blanks = [ ("gross_profit", "Gross profit"), ("operating_income", "Operating income"), ("net_income", "Net income"), ] else: story = f"A draft statement includes the {money(amount, case.unit)} of {lc(label)} in operating expenses instead of below operating income." affected = ("Operating income",) direction = "understates" unchanged = ("Gross profit", "Income before income taxes", "Net income") blanks = [ ("gross_profit", "Gross profit"), ("operating_income", "Operating income"), ("pretax_income", "Income before income taxes"), ("net_income", "Net income"), ] steps = [ f"Correct statement: gross profit {result.gross_profit:,}; operating income {result.operating_income:,}; income before income taxes {result.pretax_income:,}; net income {result.net_income:,}.", f"The draft moves {lc(label)} ({amount:,}) to another line. The amount itself does not change.", f"Draft: revenue {draft_result.revenue:,}; gross profit {draft_result.gross_profit:,}; operating income {draft_result.operating_income:,}; income before income taxes {draft_result.pretax_income:,}; net income {draft_result.net_income:,}.", f"The draft {direction} {join_words(affected)} by {amount:,}{' each' if len(affected) > 1 else ''}. {join_words(unchanged).capitalize()} are unchanged because the amount is still counted once.", ] rule = "Moving an amount to another line does not change the amount or net income. It changes the subtotals between the wrong line and the right one. Checking only net income would miss the error." if kind == "fill-blank": prompt = ( story + " What does the draft report for each of these? " + "; ".join(f"{title}: ________" for _, title in blanks) ) answer = "; ".join( f"{title} {money(draft_result.subtotal(name) if name != 'revenue' else draft_result.revenue, case.unit)}" for name, title in blanks ) return Question( qid, "misplacement", FILL, "Trace a classification error", facts, prompt, (), answer, "Exact amounts in the stated unit.", tuple(steps), rule, SOURCE_DISTORTION, ) prompt = story + " Which subtotals does the draft misstate, and by how much?" misstated = "overstated" if direction == "overstates" else "understated" correct_text = ( f"The draft {direction} {join_words(affected)} by " f"{money(amount, case.unit)}{' each' if len(affected) > 1 else ''}; {join_words(unchanged)} " "are unchanged." ) wrong = [ Choice( "", f"Every subtotal through net income is {misstated} by {money(amount, case.unit)}.", False, "The amount is still on the statement and still counted once, so income before " "income taxes and net income do not change.", ), Choice( "", "No subtotal changes, because the amount is counted once either way.", False, "The final totals do not change, but the subtotals between the wrong line and " "the right line do.", ), Choice( "", f"Net income is {misstated} by {money(amount, case.unit)}, and the earlier " "subtotals are unchanged.", False, "This is backward. The earlier subtotals change; net income does not.", ), ] choices = _letters([Choice("", correct_text, True, "Correct."), *wrong], rng) answer_choice = next(c for c in choices if c.correct) return Question( qid, "misplacement", MCQ, "Trace a classification error", facts, prompt, choices, f"({answer_choice.key}) {answer_choice.text}", "Choose one.", tuple(steps), rule, SOURCE_DISTORTION, ) def question_comparison( pair: tuple[IncomeStatementCase, IncomeStatementCase], rng: random.Random, kind: str, qid: str ) -> Question: prior, current = pair comparison = compare(prior, current) before, after = comparison.prior_result, comparison.current_result unit = current.unit facts = ( f"**{current.entity}**, years ended December 31, {prior.year} and {current.year}, in {unit}. " "Both statements are complete.\n\n" f"| Subtotal | {prior.year} | {current.year} |\n|---|---:|---:|\n" f"| Revenue | {fmt(before.revenue)} | {fmt(after.revenue)} |\n" f"| Gross profit | {fmt(before.gross_profit)} | {fmt(after.gross_profit)} |\n" f"| Operating income | {fmt(before.operating_income)} | {fmt(after.operating_income)} |\n" f"| Income before income taxes | {fmt(before.pretax_income)} | {fmt(after.pretax_income)} |\n" f"| Net income | {fmt(before.net_income)} | {fmt(after.net_income)} |" ) gain = _positive_other(current) if gain and gain[0] not in prior.other_items: facts += f"\n\nThe {current.year} income before income taxes includes a {money(gain[1], unit)} {lc(gain[0])}. There was no such item in {prior.year}." rule = ( "Change means current year less prior year. A margin compares years with different sales totals. " "The statements measure what changed; they do not show whether prices, volume, mix, or costs caused it, " "and reported income is not cash available for debt payments." ) if kind == "fill-blank": prompt = ( f"Calculate the change from {prior.year} to {current.year} in revenue: ________; gross profit: ________; " f"operating income: ________; net income: ________. Then calculate the operating margin for {prior.year}: ________ " f"and for {current.year}: ________. Round rates to one decimal place." ) answer = ( f"Revenue {money(comparison.changes['revenue'], unit)}; gross profit {money(comparison.changes['gross_profit'], unit)}; " f"operating income {money(comparison.changes['operating_income'], unit)}; net income {money(comparison.changes['net_income'], unit)}; " f"operating margin {pct(before.operating_margin)} in {prior.year} and {pct(after.operating_margin)} in {current.year}." ) steps = ( f"Revenue change: {operand(after.revenue)} {MINUS} {operand(before.revenue)} = {num(comparison.changes['revenue'])}", f"Gross profit change: {operand(after.gross_profit)} {MINUS} {operand(before.gross_profit)} = {num(comparison.changes['gross_profit'])}", f"Operating income change: {operand(after.operating_income)} {MINUS} {operand(before.operating_income)} = {num(comparison.changes['operating_income'])}", f"Net income change: {operand(after.net_income)} {MINUS} {operand(before.net_income)} = {num(comparison.changes['net_income'])}", margin_step(prior, before, "operating_margin"), margin_step(current, after, "operating_margin"), f"Operating margin change: {pts(comparison.margin_changes['operating_margin'])} percentage points", ) return Question( qid, "comparison", FILL, "Compare two years", facts, prompt, (), answer, "Exact amounts; rates within 0.1 percentage point.", steps, rule, SOURCE_COMPARE, ) variant = rng.choice(["claim", "margin-change"]) if variant == "margin-change": correct = comparison.margin_changes["operating_margin"] prompt = f"By how many percentage points did the operating margin change from {prior.year} to {current.year}? Round each margin to one decimal place first." steps = ( margin_step(prior, before, "operating_margin"), margin_step(current, after, "operating_margin"), f"Change: {pct(after.operating_margin)} {MINUS} {pct(before.operating_margin)} = {pts(correct)} percentage points", ) seen = {correct} wrong: list[Choice] = [] for value, rationale in ( ( comparison.margin_changes["gross_profit_rate"], "This is the change in the gross profit rate, which does not deduct operating expenses.", ), ( comparison.margin_changes["net_profit_margin"], "This is the change in the net profit margin, which includes nonoperating items and tax.", ), ( percent(comparison.changes["operating_income"], before.operating_income), "This is the percentage growth in operating income, not the change in margin. A margin change compares shares of revenue.", ), ( -correct, "This has the sign reversed. Change is the current year less the prior year.", ), ): if value not in seen and len(wrong) < 3: seen.add(value) wrong.append(Choice("", f"{pts(value)} points", False, rationale)) if len(wrong) < 3: raise CaseError("could not build three distinct wrong margin changes") choices = _letters([Choice("", f"{pts(correct)} points", True, "Correct."), *wrong], rng) answer_choice = next(c for c in choices if c.correct) return Question( qid, "comparison", MCQ, "Compare two years", facts, prompt, choices, f"({answer_choice.key}) {answer_choice.text}", "Within 0.1 percentage point.", steps, rule, SOURCE_COMPARE, ) change = comparison.changes["operating_income"] word = "rose" if change >= 0 else "fell" prompt = "Which claim do these two statements support?" correct_text = f"Reported operating income {word} by {money(abs(change), unit)} from {prior.year} to {current.year}." wrong = [ Choice( "", f"{current.entity} sold more units in {current.year} than in {prior.year}.", False, "The statements measure the change in reported amounts. They do not show whether prices, volume, mix, or costs caused it.", ), Choice( "", f"{current.entity} has {money(abs(comparison.changes['net_income']), unit)} more cash available for debt payments.", False, "Reported income is not cash. A lender needs the cash-flow statement and the debt terms.", ), Choice( "", f"Operating income {word} because operating expenses changed.", False, "Revenue, cost of revenue, and operating expenses all enter operating income; the subtotal alone does not isolate one cause.", ), ] choices = _letters([Choice("", correct_text, True, "Correct."), *wrong], rng) answer_choice = next(c for c in choices if c.correct) steps = ( f"Operating income change: {operand(after.operating_income)} {MINUS} {operand(before.operating_income)} = {num(change)}", "That change is measured by the statements. The reason for it is not.", ) return Question( qid, "comparison", MCQ, "State what the statements support", facts, prompt, choices, f"({answer_choice.key}) {answer_choice.text}", "Choose one.", steps, rule, SOURCE_COMPARE, ) def question_interpretation( case: IncomeStatementCase, rng: random.Random, kind: str, qid: str ) -> Question: """Multiple choice only: these skills are about reading, not arithmetic.""" result = calculate(case) unit = case.unit variants = ["classification-net-income", "label-comparability", "cash-limit"] gain = _positive_other(case) if gain: variants.append("continuing-not-recurring") variant = rng.choice(variants) facts = subtotal_block(case, result) if variant == "continuing-not-recurring": assert gain is not None facts += f"\n\nIncome before income taxes includes a {money(gain[1], unit)} {lc(gain[0])}." prompt = "Which reading of income from continuing operations is correct?" correct_text = f"Continuing operations can include the {money(gain[1], unit)} gain even if no similar sale happens next year; a reader assessing ongoing earning power should identify it." wrong = [ Choice( "", f"The {money(gain[1], unit)} gain must be removed from net income because it will not recur.", False, "A peripheral gain still enters net income. Removing it creates a different measure without saying so.", ), Choice( "", "The gain must be shown in other comprehensive income because it is not part of normal operations.", False, "OCI is a route set by accounting rules for specific items. A one-time sale is not an OCI category.", ), Choice( "", f"Because the gain is in continuing operations, {case.entity} will earn a similar gain next year.", False, "Continuing means the activity continues; it does not mean each item recurs.", ), ] steps = ( f"Income before income taxes {result.pretax_income:,} includes {lc(gain[0])} {gain[1]:,}.", "Continuing operations excludes only qualifying discontinued operations. It does not exclude isolated gains.", ) rule = "Income from continuing operations can include a gain that may happen only once. Continuing is not recurring." elif variant == "classification-net-income": label, amount = _largest_opex(case) prompt = f"A draft moves {money(amount, unit)} of {lc(label)} from operating expenses into cost of revenue. Which totals change?" correct_text = f"Gross profit falls by {money(amount, unit)}; operating income and net income are unchanged." wrong = [ Choice( "", f"Net income falls by {money(amount, unit)}.", False, "The expense is deducted once either way, so net income does not change.", ), Choice( "", f"Gross profit and net income both fall by {money(amount, unit)}.", False, "Gross profit falls, but the expense still reaches net income once, so net income is unchanged.", ), Choice( "", "Nothing changes, because the amount is the same.", False, "The amount is the same, but gross profit is a subtotal above the line where the expense now sits.", ), ] steps = ( f"Correct gross profit {result.gross_profit:,}; draft gross profit {result.gross_profit - amount:,}.", f"Operating income stays {result.operating_income:,} and net income stays {result.net_income:,} because the expense is still deducted once.", ) rule = "Moving an amount to another line does not change the amount or net income. It changes the subtotals between the two lines." elif variant == "label-comparability": other_margin = result.operating_margin + Decimal(rng.choice(["-4.5", "-2.0", "3.0", "5.5"])) prompt = ( f"{case.entity} reports an operating margin of {pct(result.operating_margin)}. Another company in the same industry " f"reports an operating margin of {pct(other_margin)} under the same label. What must you check before comparing the two rates?" ) correct_text = "Which costs each company places above operating income, using the line items and the notes." wrong = [ Choice( "", "Nothing; the same label means the two subtotals include the same costs.", False, "A subtotal name tells you where to look. It does not prove the two companies included the same kinds of costs.", ), Choice( "", "Only whether both companies are profitable.", False, "Profitability does not make two operating subtotals comparable.", ), Choice( "", "Only the revenue amounts, because both margins divide by revenue.", False, "The denominators match by definition. The question is what each numerator includes.", ), ] steps = ( margin_step(case, result, "operating_margin"), "Before comparing, inspect the lines above each company's operating income and read the notes and policies.", ) rule = "A matching subtotal label does not prove that two companies include the same costs. Check the lines and notes." else: prompt = f"A lender says the {case.year} net income of {case.entity}, {money(result.net_income, unit)}, is cash available for debt payments. Which part of that statement is measured?" correct_text = f"The {money(result.net_income, unit)} of reported net income is measured. Cash available requires the cash-flow statement, collection patterns, and the debt terms." wrong = [ Choice( "", "Both parts; net income is the cash the company earned during the year.", False, "Net income is a recognized result for the period. It is not a cash balance or a cash flow.", ), Choice( "", "Neither part; net income is an estimate with no measured content.", False, "Reported net income is measured under the accounting rules. The cash claim is what lacks support.", ), Choice( "", "The cash part; net income is unreliable, but cash is always available for debt.", False, "This is backward. The income is what the statement reports; the cash claim needs other evidence.", ), ] steps = ( f"Net income for {case.year} is {result.net_income:,}.", "The income statement does not report cash collections, payments, or debt principal due.", ) rule = "A reported income amount measures a recognized result. It is not cash available, and it does not establish future income." choices = _letters([Choice("", correct_text, True, "Correct."), *wrong], rng) answer_choice = next(c for c in choices if c.correct) return Question( qid, "interpretation", MCQ, "Read the statement", facts, prompt, choices, f"({answer_choice.key}) {answer_choice.text}", "Choose one.", steps, rule, SOURCE_COMPARE, ) OCI_ROUTES: tuple[tuple[str, str, str], ...] = ( ( "The fair value of a debt security classified as available for sale rises while the company still holds it.", "oci", "The holding gain on an available-for-sale debt security enters OCI under that classification. The classification, not the absence of a sale, controls.", ), ( "The company sells a debt security classified as available for sale and recognizes a gain.", "income", "The gain recognized on sale enters net income. A reclassification adjustment removes any earlier holding gain from OCI so it is not counted twice.", ), ( "A foreign operation's financial statements are translated into the parent company's reporting currency, producing a translation gain.", "oci", "The foreign-currency translation adjustment is an OCI category.", ), ( "The company sells land it held for its own use and recognizes a loss.", "income", "A loss on selling an asset the business used enters net income below operating income. A sale outside ordinary customer activity is not an OCI category.", ), ( "The company records depreciation on its equipment; no cash is paid this period.", "income", "Depreciation is an expense in net income. Whether cash moved does not decide the route.", ), ) def question_oci(case: IncomeStatementCase, rng: random.Random, kind: str, qid: str) -> Question: result = calculate(case) unit = case.unit rule = "Comprehensive income is net income plus other comprehensive income for the period. The accounting rule for each item decides whether it enters OCI. Accumulated OCI is the cumulative equity balance, not a period amount." if kind == "fill-blank": if not case.oci_after_tax or case.aoci_beginning is None: raise CaseError("an OCI fill-in question needs OCI lines and a beginning AOCI balance") facts = ( f"**{case.entity}**, {case.period_phrase}, in {unit}. Net income is {money(result.net_income, unit)}. " f"Accumulated other comprehensive income at the start of the year was {money(case.aoci_beginning, unit)}. " "The following after-tax amounts are supplied, with their accounting classification decided:\n\n" "| Item, net of tax | Amount |\n|---|---:|\n" + "\n".join( f"| {label} | {fmt(amount)} |" for label, amount in case.oci_after_tax.items() ) ) prompt = "Calculate other comprehensive income: ________; comprehensive income: ________; and ending accumulated OCI: ________." answer = f"OCI {money(result.oci_after_tax, unit)}; comprehensive income {money(result.comprehensive_income, unit)}; ending AOCI {money(result.aoci_ending or 0, unit)}." steps = tuple( f"{s.label}: {s.working} = {num(s.amount)}" for s in result.steps if s.label in ("Other comprehensive income", "Comprehensive income", "Ending accumulated OCI") ) return Question( qid, "oci", FILL, "From net income to comprehensive income", facts, prompt, (), answer, "Exact amounts in the stated unit.", steps, rule, SOURCE_OCI, ) text, route, reason = rng.choice(OCI_ROUTES) facts = "Assume the item is recognized this year. Use the stated accounting classification, not whether cash changed hands." prompt = f"{text} Where does the gain or loss enter?" routes = { "income": Choice( "", "Net income", route == "income", reason if route == "income" else "The stated classification sends this item to OCI, not to net income.", ), "oci": Choice( "", "Other comprehensive income (OCI)", route == "oci", reason if route == "oci" else "OCI is for items the accounting rules exclude from net income. This item enters net income.", ), "equity": Choice( "", "Directly in retained earnings, bypassing both", False, "Retained earnings changes through net income and dividends. Recognized gains and losses enter net income or OCI.", ), "cash": Choice( "", "Nowhere until cash is received or paid", False, "Recognition does not wait for cash. The classification decides the route.", ), } choices = _letters(list(routes.values()), rng) answer_choice = next(c for c in choices if c.correct) return Question( qid, "oci", MCQ, "Route a gain or loss", facts, prompt, choices, f"({answer_choice.key}) {answer_choice.text}", "Choose one.", (reason,), rule, SOURCE_OCI, ) # --------------------------------------------------------------------------- # Random scenarios # --------------------------------------------------------------------------- BUSINESSES: tuple[dict[str, Any], ...] = ( { "entity": "Harlow Fabrication", "revenue": ("Net sales",), "cost": "Cost of goods sold", "opex": ("Selling expenses", "Administrative expenses"), "asset": "unused land", }, { "entity": "Meridian Field Services", "revenue": ("Service revenue",), "cost": "Cost of services", "opex": ("Selling and marketing", "General and administrative"), "asset": "a service van", }, { "entity": "Copperline Instruments", "revenue": ("Product sales", "Installation revenue"), "cost": "Cost of revenue", "opex": ("Selling and marketing", "Research and development", "General and administrative"), "asset": "the Toledo warehouse", }, { "entity": "Bright Harbor Foods", "revenue": ("Net sales",), "cost": "Cost of goods sold", "opex": ("Selling expenses", "Administrative expenses"), "asset": "delivery equipment", }, { "entity": "Ashgrove Software", "revenue": ("Subscription revenue", "Professional services revenue"), "cost": "Cost of revenue", "opex": ("Sales and marketing", "Research and development", "General and administrative"), "asset": "an office building", }, ) def _round20(value: float) -> int: return max(20, int(round(value / 20.0)) * 20) def make_case( rng: random.Random, *, unit: str = "USD thousands", year: int = 2026, with_gain: bool = False, with_oci: bool = False, business: dict[str, Any] | None = None, base: IncomeStatementCase | None = None, ) -> IncomeStatementCase: """A plausible scenario whose amounts satisfy every course rule. Amounts are generated in thousands as multiples of 20, so a 20, 25, or 30 percent tax rate always gives a whole amount. Whole-dollar scenarios use the same figures times 1,000. With `base`, the new year keeps the same lines and varies each amount by up to 12 percent, for a two-year comparison. """ scale = 1_000 if unit == "USD" else 1 for _ in range(200): spec = business or rng.choice(BUSINESSES) if base is not None: revenue = { k: _round20(v / scale * rng.uniform(0.88, 1.12)) * scale for k, v in base.revenue.items() } cost = { k: _round20(v / scale * rng.uniform(0.88, 1.12)) * scale for k, v in base.cost_of_revenue.items() } opex = { k: _round20(v / scale * rng.uniform(0.88, 1.12)) * scale for k, v in base.operating_expenses.items() } other = { k: ( -_round20(-v / scale * rng.uniform(0.88, 1.12)) if v < 0 else _round20(v / scale * rng.uniform(0.88, 1.12)) ) * scale for k, v in base.other_items.items() if k not in base.peripheral_sales } sales: dict[str, Any] = {} else: total = rng.randrange(3_000, 20_001, 20) shares = [rng.uniform(1.0, 3.0) for _ in spec["revenue"]] revenue = {} remaining = total for index, label in enumerate(spec["revenue"]): if index == len(spec["revenue"]) - 1: revenue[label] = remaining * scale else: part = _round20(total * shares[index] / sum(shares)) revenue[label] = part * scale remaining -= part cost = {spec["cost"]: _round20(total * rng.uniform(0.50, 0.68)) * scale} opex = { label: _round20(total * rng.uniform(0.05, 0.14)) * scale for label in spec["opex"] } other = {} sales = {} if rng.random() < 0.4: other["Interest and dividend income"] = ( _round20(total * rng.uniform(0.003, 0.012)) * scale ) if with_gain or rng.random() < 0.5: label = f"Gain on sale of {spec['asset']}" carrying = _round20(total * rng.uniform(0.02, 0.08)) * scale gain = _round20(total * rng.uniform(0.01, 0.04)) * scale other[label] = gain sales[label] = {"proceeds": carrying + gain, "carrying_amount": carrying} other["Interest expense"] = -_round20(total * rng.uniform(0.01, 0.04)) * scale pretax = ( sum(revenue.values()) - sum(cost.values()) - sum(opex.values()) + sum(other.values()) ) gross = sum(revenue.values()) - sum(cost.values()) operating = gross - sum(opex.values()) if operating <= 0 or pretax <= 0: continue rate = ( base.tax_rate if base is not None and base.tax_rate is not None else Decimal(rng.choice(["0.20", "0.25", "0.30"])) ) tax = Decimal(pretax) * rate if tax != tax.to_integral_value(): continue assumptions = [ f"The sale of {spec['asset']} is outside ordinary customer sales." if sales else "There is no gain or loss on asset sales this year." ] data: dict[str, Any] = { "entity": spec["entity"], "year": year, "unit": unit, "revenue": revenue, "cost_of_revenue": cost, "operating_expenses": opex, "other_items": other, "peripheral_sales": sales, } supply_tax = base.income_tax_expense is not None if base is not None else rng.random() < 0.5 if supply_tax: data["income_tax_expense"] = int(tax) assumptions.append( "Income tax expense is supplied; do not compute it from pretax income." ) else: data["tax_rate"] = str(rate) # `pretax` is already in the stated unit; work in thousands for rounding. pretax_k = pretax / scale if base is not None and base.discontinued_after_tax: size = _round20(abs(base.discontinued_after_tax) / scale * rng.uniform(0.5, 1.5)) data["discontinued_after_tax"] = size * scale * rng.choice([-1, 1]) elif base is None and rng.random() < 0.25: size = _round20(pretax_k * rng.uniform(0.05, 0.2)) data["discontinued_after_tax"] = size * scale * rng.choice([-1, 1]) if "discontinued_after_tax" in data: assumptions.append( "The discontinued-operation result is supplied net of tax; the company determined that the disposal qualifies. Chapter 7 teaches the test." ) else: assumptions.append("There is no discontinued operation.") if with_oci or (base is None and rng.random() < 0.3): oci: dict[str, int] = { "Unrealized holding gain on available-for-sale debt securities, net of tax": ( _round20(pretax_k * rng.uniform(0.02, 0.08)) * scale ) } if rng.random() < 0.5: oci["Reclassification adjustment, net of tax"] = ( -_round20(pretax_k * rng.uniform(0.01, 0.03)) * scale ) if rng.random() < 0.4: oci["Foreign currency translation adjustment, net of tax"] = ( _round20(pretax_k * rng.uniform(0.01, 0.04)) * scale * rng.choice([-1, 1]) ) data["oci_after_tax"] = oci data["aoci_beginning"] = ( _round20(pretax_k * rng.uniform(0.05, 0.3)) * scale * rng.choice([-1, 1, 1]) ) assumptions.append( "The securities are classified as available for sale. The after-tax OCI " "amounts and their classification are supplied." ) data["assumptions"] = assumptions try: candidate = IncomeStatementCase.from_dict(data) except CaseError: continue outcome = calculate(candidate) # Chapter 6 works with profitable years; a loss year would need topics it does not teach. if outcome.net_income <= 0 or outcome.comprehensive_income <= 0: continue return candidate raise CaseError("could not generate a valid scenario; try another seed") def make_pair( rng: random.Random, *, unit: str = "USD thousands" ) -> tuple[IncomeStatementCase, IncomeStatementCase]: current = make_case(rng, unit=unit, year=2026, with_gain=rng.random() < 0.7) spec = next(b for b in BUSINESSES if b["entity"] == current.entity) prior = make_case(rng, unit=unit, year=2025, business=spec, base=current) return prior, current def generate_questions(seed: int, skill: str, kind: str, count: int, unit: str) -> list[Question]: rng = random.Random(seed) questions: list[Question] = [] for number in range(1, count + 1): chosen_skill = rng.choice(SKILLS) if skill == "mixed" else skill chosen_kind = rng.choice(KINDS) if kind == "mixed" else kind chosen_unit = rng.choice(UNITS) if unit == "mixed" else unit qid = f"{chosen_skill}-{seed}-{number}" if chosen_skill == "comparison": questions.append( question_comparison(make_pair(rng, unit=chosen_unit), rng, chosen_kind, qid) ) elif chosen_skill == "misplacement": case = make_case(rng, unit=chosen_unit, with_gain=True) questions.append(question_misplacement(case, rng, chosen_kind, qid)) elif chosen_skill == "interpretation": case = make_case(rng, unit=chosen_unit, with_gain=rng.random() < 0.6) questions.append(question_interpretation(case, rng, chosen_kind, qid)) elif chosen_skill == "oci": case = make_case(rng, unit=chosen_unit, with_oci=True) questions.append(question_oci(case, rng, chosen_kind, qid)) elif chosen_skill == "margin": questions.append( question_margin(make_case(rng, unit=chosen_unit), rng, chosen_kind, qid) ) else: questions.append( question_subtotal(make_case(rng, unit=chosen_unit), rng, chosen_kind, qid) ) return questions # --------------------------------------------------------------------------- # Self-test against the course's printed examples # --------------------------------------------------------------------------- def self_test() -> int: checks = 0 def expect(condition: bool, message: str) -> None: nonlocal checks checks += 1 if not condition: raise AssertionError(message) sable = calculate(COURSE_CASES["sable-ridge-2026"]) expect(sable.revenue == 7_000_000, "Sable 2026 revenue") expect(sable.gross_profit == 3_200_000, "Sable 2026 gross profit") expect(sable.operating_income == 860_000, "Sable 2026 operating income") expect(sable.pretax_income == 1_000_000, "Sable 2026 pretax income") expect(sable.continuing_income == 750_000, "Sable 2026 continuing income") expect(sable.net_income == 600_000, "Sable 2026 net income") expect(sable.oci_after_tax == 30_000, "Sable 2026 OCI") expect(sable.comprehensive_income == 630_000, "Sable 2026 comprehensive income") expect(sable.aoci_ending == 75_000, "Sable 2026 ending AOCI") expect(pct(sable.gross_profit_rate) == "45.7%", "Sable 2026 gross profit rate") expect(pct(sable.operating_margin) == "12.3%", "Sable 2026 operating margin") expect(pct(sable.net_profit_margin) == "8.6%", "Sable 2026 net profit margin") sable_prior = calculate(COURSE_CASES["sable-ridge-2025"]) expect(sable_prior.revenue == 6_400_000, "Sable 2025 revenue") expect(sable_prior.gross_profit == 2_880_000, "Sable 2025 gross profit") expect(sable_prior.operating_income == 700_000, "Sable 2025 operating income") expect(sable_prior.pretax_income == 600_000, "Sable 2025 pretax income") expect(sable_prior.continuing_income == 450_000, "Sable 2025 continuing income") expect(sable_prior.net_income == 585_000, "Sable 2025 net income") expect(pct(sable_prior.gross_profit_rate) == "45.0%", "Sable 2025 gross profit rate") expect(pct(sable_prior.operating_margin) == "10.9%", "Sable 2025 operating margin") expect(pct(sable_prior.net_profit_margin) == "9.1%", "Sable 2025 net profit margin") sable_compare = compare(COURSE_CASES["sable-ridge-2025"], COURSE_CASES["sable-ridge-2026"]) expect(sable_compare.changes["revenue"] == 600_000, "Sable revenue change") expect(sable_compare.changes["gross_profit"] == 320_000, "Sable gross profit change") expect(sable_compare.changes["operating_income"] == 160_000, "Sable operating income change") expect(sable_compare.changes["pretax_income"] == 400_000, "Sable pretax change") expect(sable_compare.changes["continuing_income"] == 300_000, "Sable continuing change") expect(sable_compare.changes["net_income"] == 15_000, "Sable net income change") sable_draft = calculate( misplaced_draft( COURSE_CASES["sable-ridge-2026"], "gain-in-revenue", "Gain on sale of the Dayton warehouse", ) ) expect( (sable_draft.revenue, sable_draft.gross_profit, sable_draft.operating_income) == (7_180_000, 3_380_000, 1_040_000), "Sable draft overstated subtotals", ) expect( (sable_draft.pretax_income, sable_draft.net_income) == (1_000_000, 600_000), "Sable draft unchanged totals", ) north = calculate(COURSE_CASES["northline-2026"]) expect( (north.gross_profit, north.operating_income, north.pretax_income, north.net_income) == (3_600, 1_000, 900, 675), "Northline 2026 subtotals", ) expect( pct(north.gross_profit_rate) == "40.0%" and pct(north.operating_margin) == "11.1%", "Northline 2026 rates", ) north_prior = calculate(COURSE_CASES["northline-2025"]) expect( ( north_prior.gross_profit, north_prior.operating_income, north_prior.pretax_income, north_prior.net_income, ) == (3_360, 860, 600, 450), "Northline 2025 subtotals", ) expect( pct(north_prior.gross_profit_rate) == "40.0%" and pct(north_prior.operating_margin) == "10.2%", "Northline 2025 rates", ) north_compare = compare(COURSE_CASES["northline-2025"], COURSE_CASES["northline-2026"]) expect( [ north_compare.changes[k] for k in ("revenue", "gross_profit", "operating_income", "pretax_income", "net_income") ] == [600, 240, 140, 300, 225], "Northline changes", ) north_draft = calculate( misplaced_draft( COURSE_CASES["northline-2026"], "gain-in-revenue", "Gain on sale of unused land" ) ) expect( ( north_draft.revenue, north_draft.gross_profit, north_draft.operating_income, north_draft.pretax_income, north_draft.net_income, ) == (9_200, 3_800, 1_200, 900, 675), "Northline draft", ) # Validation rules def rejects(message: str, **changes: Any) -> None: try: COURSE_CASES["northline-2026"].replace(**changes) except CaseError: expect(True, message) return raise AssertionError(f"validator accepted {message}") rejects("negative cost", cost_of_revenue={"Cost of goods sold": -5_400}) rejects( "subtotal as a line", operating_expenses={"Selling expenses": 1_500, "Gross profit": 3_600} ) rejects("tax amount and rate together", tax_rate="0.25") rejects("fractional tax", income_tax_expense=None, tax_rate="0.255") rejects( "gain not equal to proceeds less carrying amount", peripheral_sales={"Gain on sale of unused land": {"proceeds": 500, "carrying_amount": 200}}, ) rejects( "proceeds treated as revenue", peripheral_sales={"Net sales": {"proceeds": 9_000, "carrying_amount": 0}}, ) rejects("float amount", revenue={"Net sales": 9_000.5}) rejects("unknown unit", unit="EUR") for name, case in COURSE_CASES.items(): expect( IncomeStatementCase.from_dict(json.loads(json.dumps(case.to_dict()))) == case, f"{name} JSON round trip", ) # The course's Unit 2 case file splits Sable's cost of revenue by line and # states a tax rate instead of a supplied amount; the reading's totals hold. course_record = { "id": "SCEN:acc300/reading-2-1/sable-ridge-2026", "entity": "Sable Ridge Instruments", "period": "Year ended December 31, 2026", "unit": "USD", "role": "worked-reading", "tax_rate": "0.25", "revenue": { "Instrument sales": 4_200_000, "Consumables": 1_850_000, "Installation and calibration": 950_000, }, "cost_of_revenue": { "Instruments": 2_520_000, "Consumables": 740_000, "Installation and calibration": 540_000, }, "operating_expenses": COURSE_CASES["sable-ridge-2026"].operating_expenses, "other_items": COURSE_CASES["sable-ridge-2026"].other_items, "discontinued_after_tax": -150_000, "oci_after_tax": {"Holding gain": 60_000, "Reclassification adjustment": -30_000}, "stipulated_amounts": {"aoci_beginning": 45_000, "unrealized_gain_pretax": 80_000}, "assumptions": ["Supplied for presentation practice."], "surfaces": ["site/src/pages/book-preview/_section-2-1.md"], } converted = calculate(from_course_case(course_record)) expect( ( converted.income_tax, converted.net_income, converted.comprehensive_income, converted.aoci_ending, ) == (250_000, 600_000, 630_000, 75_000), "course case file conversion matches the reading", ) try: compare(COURSE_CASES["northline-2026"], COURSE_CASES["sable-ridge-2026"]) except CaseError: expect(True, "comparison across companies rejected") else: raise AssertionError("comparison across companies accepted") # Generator invariants across seeds, skills, kinds, and units for seed in range(1, 61): for skill in SKILLS: for kind in KINDS: for unit in UNITS: for question in generate_questions(seed, skill, kind, 1, unit): expect( bool(question.answer) and bool(question.steps), f"{question.id} has an answer and steps", ) if question.format == MCQ: texts = [c.text for c in question.choices] expect( len(question.choices) == 4 and len(set(texts)) == 4, f"{question.id} has four distinct choices", ) expect( sum(c.correct for c in question.choices) == 1, f"{question.id} has one correct choice", ) expect( all(c.rationale for c in question.choices), f"{question.id} explains every choice", ) else: expect( not question.choices and "________" in question.prompt, f"{question.id} is a fill-in question", ) rendered = render_markdown(question) expect(ANSWER_MARKER in rendered, f"{question.id} renders an answer key") expect( "the the " not in rendered.lower() and " -" not in rendered.split(ANSWER_MARKER)[1].replace("---", ""), f"{question.id} has clean prose and minus signs", ) json.dumps(question.to_dict()) case = make_case(random.Random(seed), with_gain=True, with_oci=True) result = calculate(case) expect( result.net_income == result.continuing_income + case.discontinued_after_tax, "net income identity", ) expect( result.comprehensive_income == result.net_income + result.oci_after_tax, "comprehensive income identity", ) expect(result.pretax_income - result.income_tax == result.continuing_income, "tax identity") biggest_gain = _positive_other(case) for pattern, label in ( ("gain-in-revenue", biggest_gain[0] if biggest_gain else ""), ("interest-in-operating-expenses", "Interest expense"), ): if label: draft = calculate(misplaced_draft(case, pattern, label)) expect( (draft.pretax_income, draft.net_income) == (result.pretax_income, result.net_income), "misplacement keeps final totals", ) expect( generate_questions(seed, "mixed", "mixed", 3, "mixed") == generate_questions(seed, "mixed", "mixed", 3, "mixed"), "same seed, same questions", ) return checks # --------------------------------------------------------------------------- # Command line # --------------------------------------------------------------------------- SCHEMA_GUIDE = """\ Field guide for a scenario file (JSON; YAML also works if PyYAML is installed): entity Company name. year Four-digit year; the period is the year ended December 31. unit "USD" or "USD thousands". revenue Customer sales lines, positive amounts. Not gains. cost_of_revenue Cost of goods and services sold, positive amounts. operating_expenses Selling, research, administrative, and other operating costs or losses, positive amounts. other_items Nonoperating lines, signed: gains and investment income positive, interest expense and losses negative. income_tax_expense Supplied tax amount. Use this OR tax_rate, not both. tax_rate Simplifying assumption such as "0.25". The tax must come out to a whole amount. discontinued_after_tax Optional signed amount, already net of tax. Supplying it stipulates that the disposal qualifies (Chapter 7). oci_after_tax Optional signed after-tax OCI lines with the accounting classification decided. aoci_beginning Optional beginning accumulated OCI balance. peripheral_sales Optional facts for a gain line in other_items: {"proceeds": ..., "carrying_amount": ...}. The line must equal proceeds less carrying amount. assumptions Sentences that state the supplied judgments. Do not enter subtotals as lines. Do not mix signs within a group. """ def _read_file(path: str) -> Any: text = Path(path).read_text(encoding="utf-8") if path.endswith((".yaml", ".yml")): try: import yaml # type: ignore[import-not-found] except ImportError as error: raise CaseError("YAML files need PyYAML; save the scenario as JSON instead") from error return yaml.safe_load(text) return json.loads(text) def from_course_case(record: dict[str, Any]) -> IncomeStatementCase: """Convert one income-statement case from the course's Unit 2 case file. The course file (corpus/data/acc300/unit-2-income-statement-cases.yaml) keeps the same populations but adds scenario IDs, teaching roles, and a `stipulated_amounts` group. Supplied tax expense and the beginning AOCI balance come from that group; a case without supplied tax states a rate. """ period = str(record.get("period", "")) year = period.rsplit(" ", 1)[-1] if not year.isdigit(): raise CaseError(f"course case period must end in a year: {period!r}") stipulated = dict(record.get("stipulated_amounts") or {}) data: dict[str, Any] = { "entity": record.get("entity"), "year": int(year), "unit": record.get("unit"), "revenue": record.get("revenue"), "cost_of_revenue": record.get("cost_of_revenue"), "operating_expenses": record.get("operating_expenses"), "other_items": record.get("other_items") or {}, "discontinued_after_tax": record.get("discontinued_after_tax", 0), "oci_after_tax": record.get("oci_after_tax") or {}, "assumptions": list(record.get("assumptions") or []), } if "income_tax_expense" in stipulated: data["income_tax_expense"] = stipulated["income_tax_expense"] elif record.get("tax_rate") is not None: data["tax_rate"] = str(record["tax_rate"]) for key in ("aoci_beginning", "opening_aoci"): if key in stipulated: data["aoci_beginning"] = stipulated[key] break return IncomeStatementCase.from_dict(data) def _load_scenario(path: str, case_slug: str | None = None) -> IncomeStatementCase: """Load a scenario file, or one named case from the course's Unit 2 case file.""" data = _read_file(path) if isinstance(data, dict) and "cases" in data and "schema_version" in data: cases = data["cases"] if not case_slug: names = ", ".join(sorted(cases)) raise CaseError(f"this is the course case file; choose one with --case: {names}") if case_slug not in cases: raise CaseError(f"unknown case {case_slug!r}; choices: {', '.join(sorted(cases))}") return from_course_case(cases[case_slug]) if case_slug: raise CaseError("--case applies only to the course case file") return IncomeStatementCase.from_dict(data) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Checked income-statement practice for ACC 300 Chapter 6." ) sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("self-test", help="recompute the course examples and check the generator") gen = sub.add_parser("generate", help="print practice questions") gen.add_argument("--seed", type=int, default=1) gen.add_argument("--count", type=int, default=3) gen.add_argument("--skill", choices=(*SKILLS, "mixed"), default="mixed") gen.add_argument("--kind", choices=(*KINDS, "mixed"), default="mixed") gen.add_argument("--output", choices=("markdown", "json"), default="markdown") gen.add_argument("--unit", choices=(*UNITS, "mixed"), default="mixed") ex = sub.add_parser("example", help="print a course case with every step") ex.add_argument("name", nargs="?") ex.add_argument("--prior", help="course case name for a two-year comparison") ex.add_argument("--list", action="store_true") solve = sub.add_parser("solve", help="validate a scenario file and print the worked statement") solve.add_argument("file", help="a scenario file, or the course's Unit 2 case file") solve.add_argument("--prior", help="scenario file for the prior year") solve.add_argument( "--case", help="case name inside the course case file, such as northline-2026" ) solve.add_argument("--prior-case", help="prior-year case name inside the same course case file") sub.add_parser("schema", help="print a scenario template and field guide") args = parser.parse_args(argv) try: if args.command == "self-test": checks = self_test() print( f"PASS: {checks} checks. Course examples recompute as printed in Chapter 6. Version {VERSION}." ) elif args.command == "generate": if args.count < 1 or args.count > 25: parser.error("--count must be between 1 and 25") if args.skill == "interpretation" and args.kind == "fill-blank": print("Note: interpretation questions are multiple choice.", file=sys.stderr) questions = generate_questions(args.seed, args.skill, args.kind, args.count, args.unit) if args.output == "json": print( json.dumps( { "version": VERSION, "seed": args.seed, "questions": [q.to_dict() for q in questions], }, indent=2, ensure_ascii=False, ) ) else: print(f"# ACC 300 Chapter 6 practice · seed {args.seed} · version {VERSION}\n") print("\n\n---\n\n".join(render_markdown(q) for q in questions)) elif args.command == "example": if args.list or not args.name: for name, case in COURSE_CASES.items(): print(f"{name}: {case.entity}, {case.period}, {case.unit}") return 0 if args.name not in COURSE_CASES: parser.error(f"unknown course case {args.name}; use --list") prior_name = args.prior or COURSE_PRIORS.get(args.name) prior = COURSE_CASES[prior_name] if prior_name else None print(worked_case(COURSE_CASES[args.name], prior)) elif args.command == "solve": case = _load_scenario(args.file, args.case) prior = None if args.prior_case: prior = _load_scenario(args.file, args.prior_case) elif args.prior: prior = _load_scenario(args.prior) print(worked_case(case, prior)) elif args.command == "schema": print(json.dumps(COURSE_CASES["northline-2026"].to_dict(), indent=2)) print() print(SCHEMA_GUIDE) except CaseError as error: print(f"Scenario rejected: {error}", file=sys.stderr) return 2 except AssertionError as error: print(f"SELF-TEST FAILED: {error}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main())