Per%Sense

Quick Start

Get up and running with Per%Sense in 5 minutes.

On this page:

For a per-screen field-by-field reference, see the full Help document.

Prerequisites

No database, no external services, no build step for the frontend. The HTML/CSS/JS is embedded in the Go binary via go:embed.

Build and Run

From the project root:

# Quick run (no compile artifact)
./start_server.sh
# or equivalently:
go run ./cmd/persense

# Compiled binary
go build -o persense ./cmd/persense
./persense                  # default port 8080
./persense -port 9090       # custom port

Open http://localhost:8080 in a browser.

Using the Screens

The home screen offers three calculators. Each accepts inputs as hard data (your numbers) or returns soft data (computed answers).

Mortgage

Compare loan options, compute payments, and evaluate points-vs-rate trade-offs. Fill in any consistent combination of Price, % Down (or Cash or Amt Borrowed), Rate, Years, Tax, optional Points and Balloon. The Calc engine solves for whichever of Price, Monthly, or Balloon Amount is left blank. APR is computed when there's enough data. Use Compare APR to find the crossover term between two rows.

Detailed reference: Help → Mortgage Screen

Amortization

Generate a full payment schedule. Required: Loan Amount, Loan Date, Rate %, Pmts/Yr, and either # Periods or Last Pmt Date. Optional: 1st Pmt Date (defaults to Loan Date + 1 period if omitted), Payment (leave blank and the system computes it exactly via the annuity formula).

The Amortization screen supports field-presence dispatch on the top row: omit one of First Pmt Date, Last Pmt Date, # Periods, or Payment and Per%Sense fills it in.

The Advanced Options panel adds:

OptionWhat it does
PrepaymentsExtra periodic payments between two dates at a given frequency
BalloonsOne-time lump payments on specific dates
AdjustmentsRate and/or payment changes on specific dates (ARMs)
MoratoriumInterest-only deferment until a given date
TargetMinimum principal reduction per payment (payment adjusts upward if needed)
Skip MonthsMonths with zero payment, e.g. 6-8,12 skips Jun–Aug and Dec

Filling any Advanced field automatically switches the engine into fancy mode.

DOS-faithful behavior: when both Target and Skip Months are set, the target's minimum-principal-reduction overrides the skip-month zeroing. This matches the original DOS code.

Validation: Per%Sense rejects inconsistent inputs before computing a schedule. Common rejections: balloon date before 1st Pmt Date; two rate adjustments on the same day; adjustment date on/before the Loan Date or on/after the Last Pmt Date; moratorium first-repay before 1st Pmt Date; target principal reduction larger than Amount / # Periods. The full list lives on the Help page.

Detailed reference: Help → Amortization Screen

Present Value

Discounted-cash-flow analysis. Two grids:

Plus the top-level controls: As-of Date, Rate Type, Rate %, Present Value (read-only output).

Backward solve — leave one field blank and provide a target Present Value:

Leave blankThe system solves for…
RateThe IRR (internal rate of return)
As-of DateThe date that produces the target PV
Lump-sum amountThe missing payment value
Lump-sum dateWhen the payment occurs
Periodic amount, fromDate, or toDateThat field

Life contingency and variable-rate schedules are exposed as collapsible sections.

Detailed reference: Help → Present Value Screen

REST API

Five POST endpoints, all accepting and returning JSON.

EndpointPurpose
POST /api/mortgage/calcMortgage row Calc with optional APR
POST /api/mortgage/compareCompare two mortgages' APRs, including crossover time
POST /api/mortgage/whatifGenerate a what-if table by stepping one field
POST /api/amortization/calcSchedule generation; supports Advanced Options and backward solve
POST /api/presentvalue/calcForward PV or backward solve
GET  /api/health{"status":"ok"}

Optional fields are JSON pointers: omit them entirely (do not send null) to indicate "blank".

Mortgage — forward

curl -s -X POST http://localhost:8080/api/mortgage/calc \
  -H 'Content-Type: application/json' \
  -d '{
    "price":   200000,
    "pctDown": 0.20,
    "years":   30,
    "rate":    0.06,
    "points":  0
  }' | jq

Response includes monthly, cash, financed, and apr if there's enough data.

Mortgage — compare two APRs

Send two mortgages as a and b. The response gives each full-term APR and, when the APRs cross, the crossover APR and the holding period beyond which the other loan becomes cheaper.

curl -s -X POST http://localhost:8080/api/mortgage/compare \
  -H 'Content-Type: application/json' \
  -d '{
    "a": {"price":200000,"pctDown":0.20,"years":30,"rate":0.06,"points":0},
    "b": {"price":200000,"pctDown":0.20,"years":30,"rate":0.0575,"points":0.02}
  }' | jq

Response fields: apr1, apr2, crossoverApr, crossoverYears, summary.

Mortgage — what-if table

Step one field of a base mortgage across count rows. vary is one of rate, years, points, pctDown, price, or monthly; increment is the per-row step.

curl -s -X POST http://localhost:8080/api/mortgage/whatif \
  -H 'Content-Type: application/json' \
  -d '{
    "base": {"price":200000,"pctDown":0.20,"years":30,"rate":0.06,"points":0},
    "vary": "rate",
    "increment": 0.0025,
    "count": 5
  }' | jq

Response is rows[], each a fully-computed mortgage line (row 1 is the base; each subsequent row steps vary by increment).

Amortization — Solve for Payment (omit payment)

Send the loan parameters and leave payment at zero (or omit it). The handler treats a zero payment as "blank" and the engine computes it via the annuity formula:

curl -s -X POST http://localhost:8080/api/amortization/calc \
  -H 'Content-Type: application/json' \
  -d '{
    "amount":    250000,
    "loanDate":  "2024-01-01",
    "rate":      0.06,
    "perYr":     12,
    "nPeriods":  360
  }' | jq

Note that firstDate is also omitted — the engine defaults it to 2024-02-01 (loanDate + 1 month). The response's first schedule row will show the computed Payment of ~$1,498.88.

Amortization — Solve for Amount (omit amount)

Omit amount and supply rate and payment; the handler dispatches to the loan-amount back-solver. (Omitting both amount and rate is instead the term-derivation shortcut, so supply at least the rate here.)

curl -s -X POST http://localhost:8080/api/amortization/calc \
  -H 'Content-Type: application/json' \
  -d '{
    "rate":     0.06,
    "loanDate": "2024-01-01",
    "perYr":    12,
    "nPeriods": 360,
    "payment":  1498.88
  }' | jq

The response's amount field carries the solved principal — here ≈ 250000.62 (the cents are an artifact of the payment being rounded to whole cents) — alongside the full schedule.

Amortization — Solve for Rate (omit rate)

Omit rate and supply amount and payment; the handler dispatches to the rate back-solver (Newton iteration on the schedule residual). The term may be given as nPeriods or as lastDate — the handler derives the period count first when needed.

curl -s -X POST http://localhost:8080/api/amortization/calc \
  -H 'Content-Type: application/json' \
  -d '{
    "amount":   250000,
    "loanDate": "2024-01-01",
    "perYr":    12,
    "nPeriods": 360,
    "payment":  1498.88
  }' | jq

The response's rate field carries the solved rate — here ≈ 0.06 — alongside the full schedule.

Amortization — Advanced Options

curl -s -X POST http://localhost:8080/api/amortization/calc \
  -H 'Content-Type: application/json' \
  -d '{
    "amount":    200000,
    "loanDate":  "2024-01-01",
    "firstDate": "2024-02-01",
    "rate":      0.06,
    "perYr":     12,
    "nPeriods":  360,
    "payment":   1199.10,

    "prepayments": [
      {"startDate":"2024-02-01","stopDate":"2029-02-01","perYr":12,"amount":100}
    ],
    "balloons":    [{"date":"2030-01-01","amount":50000}],
    "adjustments": [{"date":"2027-01-01","rate":0.05}],
    "moratorium":  "2025-01-01",
    "targetAmt":   500,
    "skipMonths":  "12"
  }' | jq

Returns schedule[] (one row per payment), totalPaid, totalInterest.

Present Value — forward and backward

Forward (compute PV from inputs):

curl -s -X POST http://localhost:8080/api/presentvalue/calc \
  -H 'Content-Type: application/json' \
  -d '{
    "asOfDate": "2024-01-01",
    "rate":     0.06,
    "lumpSums": [
      {"date":"2025-01-01","amount":10000},
      {"date":"2026-01-01","amount":10000}
    ],
    "periodics": [
      {"fromDate":"2024-02-01","toDate":"2034-01-01","perYr":12,"amount":500}
    ]
  }' | jq

Solve for the unknown rate — omit rate, supply sumValue:

curl -s -X POST http://localhost:8080/api/presentvalue/calc \
  -H 'Content-Type: application/json' \
  -d '{
    "asOfDate": "2024-01-01",
    "sumValue": 18334.71,
    "lumpSums": [{"date":"2026-01-01","amount":20000}]
  }' | jq

Solve for a missing lump-sum amount — omit amount on the row, supply sumValue:

curl -s -X POST http://localhost:8080/api/presentvalue/calc \
  -H 'Content-Type: application/json' \
  -d '{
    "asOfDate": "2024-01-01",
    "rate":     0.06,
    "sumValue": 9433.96,
    "lumpSums": [{"date":"2025-01-01"}]
  }' | jq

The response shape is the same as forward calc; the previously-blank field will be populated.

Testing

# Whole suite
go test ./...

# One package, verbose
go test ./internal/finance/presentvalue/ -v

# Single test
go test ./internal/finance/presentvalue/ -run TestRoundTripRate -v

# DOS reference-data cross-checks only
go test ./internal/finance/ -run TestCrossCheck -v

The DOS reference values used by the cross-checks live at legacy/reference-output/refdata.json. They were generated by legacy/testharness/refdata.pas under Free Pascal. When adding new financial functions, run the harness and append new entries; do not hand-edit the JSON.

Test layout

FileCoverage
internal/finance/crosscheck_test.goDOS forward regression cases
internal/finance/crosscheck_backward_test.goDOS regression for backward solvers
presentvalue/backward_test.goBackwardCalc round-trip + FirstPass classification
presentvalue/backward_boundary_test.goThreshold/edge cases (cola=rate, near-teeny rate, etc.)
amortization/backward_test.goSolveLoanAmount, SolveRate
amortization/advanced_test.goEach Advanced Option in isolation
mortgage/rowgen_test.goRow generation
api/pv_backward_test.goBackward PV via HTTP
api/amort_advanced_test.goAdvanced Options via HTTP

Project Layout

persense-port/
├── CLAUDE.md             ← read first; project conventions and ported-status
├── go.mod
├── start_server.sh       ← one-line `go run` shortcut
├── cmd/persense/
│   ├── main.go           ← HTTP server, /api/* routes, embeds static
│   └── static/
│       ├── index.html    ← single-page UI (Tailwind CDN + vanilla JS)
│       ├── help.html     ← per-screen field reference
│       └── quickstart.html  ← (this page)
├── internal/
│   ├── api/              ← HTTP handlers
│   ├── dateutil/         ← Date arithmetic ported from INTSUTIL.pas
│   ├── fileio/           ← Legacy file I/O
│   ├── finance/          ← All financial logic
│   │   ├── actuarial/
│   │   ├── amortization/ ← engine.go, backward.go, types.go
│   │   ├── interest/     ← Exxp, Lnn, Round2, RateFromYield, …
│   │   ├── mortgage/     ← mortgage.go, rowgen.go
│   │   └── presentvalue/ ← calc.go, backward.go, types.go
│   └── types/            ← DateRec, status enums, constants
├── docs/
│   ├── requirements.md   ← Worksheet specs
│   ├── discrepancies.md  ← Known DOS↔port behavioral differences
│   ├── missing_flows.md  ← Field-presence dispatch audit
│   └── QUICKSTART.md     ← Markdown source of this page
├── legacy/
│   ├── src/dos_source/   ← Original DOS Pascal — READ-ONLY, financial authority
│   ├── src/win_source/   ← Windows Pascal port — UI/help authority
│   └── reference-output/refdata.json  ← DOS-known-good test values
└── pkg/                  ← (currently empty)

Common Tasks

Add a new financial function

  1. Read the original Pascal in legacy/src/dos_source/
  2. Implement in the appropriate internal/finance/<area>/ package
  3. Add a // Ported from legacy/src/dos_source/<File>.pas: <function> comment
  4. Write a _test.go with at least: one round-trip test, one DOS-regression test driven from refdata.json, and threshold/boundary cases per the patterns in backward_boundary_test.go
  5. If new reference values are needed, add them to legacy/testharness/refdata.pas, regenerate refdata.json, and update the refData struct in internal/finance/crosscheck_test.go

Wire a new field through the API

  1. Add the request type's pointer field in internal/api/handlers.go
  2. Translate nil → StatusEmpty, present → InOutInput in the handler
  3. Match the mortgage handler's pattern (lines 162–207 of handlers.go)
  4. Add an API integration test in internal/api/*_test.go
  5. If the field gates a new code path, also add a UI input in cmd/persense/static/index.html and update getAmzInput / getMortgageInput / getPVInput in the embedded JS

Debug a calculation

  1. Find the DOS reference value: search refdata.json for the input shape, or run the original program if available
  2. Confirm forward calc matches in crosscheck_test.go
  3. If forward is correct but backward is wrong, run the round-trip test in the appropriate backward_test.go
  4. Cross-reference the DOS source — exact line numbers are in the ported function's GoDoc comments
Most-cited Pascal files (line ranges noted in port comments):

← Back to Per%Sense  |  Full Help reference