invoice/CLAUDE.md
Claude 8cdb4bd72b
Fix FX rate convention to match market-standard quoting
Users were confused by the "X foreign = 1 local" convention, which is the
inverse of how exchange rates are normally quoted (e.g. "1 USD = 35 THB").
Flip to the market-standard "1 foreign = X local" direction, updating the
rate label, the price calculation (per * rate instead of per / rate), and
the PDF note.

Note: existing localStorage data using the old convention will produce
incorrect prices until users re-enter their exchange rates.

https://claude.ai/code/session_0151QtsUhzXmgzEhSvXG2SDt
2026-06-01 17:51:52 +00:00

4.8 KiB

CLAUDE.md — Freelance Invoicing App

Project overview

Single-file browser app for generating freelance invoices as PDF or print preview. No backend, no build step. All app logic lives in app/index.html (~1600 lines: HTML + embedded CSS + embedded JS). Runtime config is loaded from app/config.yml via fetch().

Running locally

Must be served over HTTP — fetch() for config.yml fails on file://.

npx serve app          # recommended
python3 -m http.server 8000   # run from app/

No package.json, no bundler, no install step.

Architecture

app/
  index.html    — entire application
  config.yml    — runtime config (recipients, products, translations, etc.)
docs/
  devdocs/Specs.md
  user-guide.md / admin-guide.md / dev-guide.md

CDN dependencies (loaded via <script> tags in index.html):

  • js-yaml 4.1.0 (cdnjs) — parses config.yml
  • jsPDF 2.5.1 (cdnjs) — PDF generation

Boot sequence: loadCfg()boot()buildLangBar(), buildForm(), restoreStorage(), loadLines() || addLine()

Key globals:

  • cfg — parsed config object
  • lang — active language code
  • lid / lines — line ID counter + map of active invoice line IDs
  • tlid / tLines — tax-line ID counter + map
  • _loading — bool; true while loadLines() restores from localStorage; blocks saveLines() writes

localStorage keys:

  • inv_data_v1 — form field values (all [data-ls] elements)
  • inv_lines_v1 — serialised line array
  • inv_generated_v1 — bump flag; invoice number increments on next load after Generate
  • zoomIdx — font-size index

PDF / preview: gatherData() feeds both buildPreviewHTML() (overlay) and buildPDF() (file download). Uses jsPDF with Helvetica, mm units; paper size from config (a4 or letter).

i18n: t(key) looks up cfg.translations[key][lang], falls back to default-code. relabel() re-applies all translatable labels on language switch.

Critical state & invariants

Do not break these behaviours:

  • Charge-to: value "" → all CT fields locked. Predefined entry → fill + lock. "other" → unlock.
  • Line description: value "" → QTY/UOM/Price disabled. Predefined → UOM locked, Price editable. "other" → all three editable.
  • FX: FX Yes → Price locked, calculated as per_item_foreign / exchange_rate. FX No → Price re-enabled.
  • _loading guard: saveLines() must no-op when _loading === true; set _loading = true for the entire duration of loadLines().
  • addLine() must return iloadLines() depends on the returned ID.
  • saveStorage() is triggered by change events on all [data-ls] elements; do not remove that listener.
  • Invoice number bump: occurs on the next page load after Generate via the inv_generated_v1 flag; do not bump on Generate itself.
  • FX rate convention: 1 foreign unit = X local units (market-standard quote). price_local = price_foreign * exchange_rate.

Config structure (config.yml)

Section Purpose
default-code / languages Available UI languages and default
hide-payment-info Bool; omits payment block from PDF
date-format / paper-format Output formatting
tax-types Tax rows available in the tax table
uom Units of measure for line items
charge-to Recipients; each may have project-codes and currency
project-codes Global fallback project code list
products Predefined line items; multilingual description/uom/price
currencies Currency codes and exchange rates
translations All UI strings keyed by language code

Config changes (new recipients, products, translations) never require JS changes.

Common tasks

Add a persisted form field

  1. Add the element to buildForm() with a data-ls="your_key" attribute.
  2. Nothing else — saveStorage() and restoreStorage() handle it automatically.
  3. If the label is translatable, add the key to cfg.translations in config.yml and update relabel() to set it.

Add a new line-level field

  1. Add the input to the line row HTML in addLine().
  2. Add the field to the object written in saveLines().
  3. Read it back in loadLines() when reconstructing each line.
  4. If translatable, update relabel().

Add a new language

  1. Add the language code to languages in config.yml.
  2. Add a translation entry for every key in cfg.translations using the new code.
  3. Set default-code to the new code if it should be the default.
  4. No JS changes needed; buildLangBar() and t() pick it up automatically.

Add a new product

Add an entry under products in config.yml:

products:
  - id: my_product
    description:
      en: "Widget"
      fr: "Gadget"
    uom:
      en: "ea"
    price: 99.00

No JS changes needed.