invoice/CLAUDE.md
Claude 501404ed7f
Add bidirectional FX rate entry
Replace the fixed-convention exchange rate label with an inline expression:
[amount] <currency> per {other}, where the currency dropdown lets users
pick which side of the rate to enter. Covers both "35 THB per USD" and
"0.028 USD per THB" without requiring mental inversion.

Formula: price_local = per * rate when local currency is in numerator,
         price_local = per / rate when foreign currency is in numerator.

rcur is persisted to localStorage and included in the PDF note.

https://claude.ai/code/session_0151QtsUhzXmgzEhSvXG2SDt
2026-06-01 17:56:18 +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: User picks which currency goes in the numerator via rcur dropdown. If rcur === lcy: price_local = per * rate. If rcur === fcy: price_local = per / 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.