diff --git a/.claude/skills/openspec-apply-change/SKILL.md b/.claude/skills/openspec-apply-change/SKILL.md index 1375861..40f35b3 100644 --- a/.claude/skills/openspec-apply-change/SKILL.md +++ b/.claude/skills/openspec-apply-change/SKILL.md @@ -12,9 +12,18 @@ metadata: Implement tasks from an OpenSpec change. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. - -**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. +**Store selection:** If the user names a store (a store is a standalone OpenSpec +repo registered on this machine) or the work lives in one, run +`openspec store list --json` to discover registered store ids, then pass +`--store ` on the commands that read or write specs and changes +(`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, +`doctor`, `context`). Other commands do not take the flag. Hints printed by +commands already carry the flag; keep it on follow-ups. Without a store, +commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name. If omitted, check if it can be +inferred from conversation context. If vague or ambiguous you MUST prompt for +available changes. **Steps** @@ -23,9 +32,11 @@ Implement tasks from an OpenSpec change. If a name is provided, use it. Otherwise: - Infer from conversation context if the user mentioned a change - Auto-select if only one active change exists - - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + - If ambiguous, run `openspec list --json` to get available changes and use + the **AskUserQuestion tool** to let the user select - Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + Always announce: "Using change: " and how to override (e.g., + `/opsx:apply `). 2. **Check status to understand the schema** ```bash @@ -33,8 +44,10 @@ Implement tasks from an OpenSpec change. ``` Parse the JSON to understand: - `schemaName`: The workflow being used (e.g., "spec-driven") - - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints - - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit + constraints + - Which artifact contains the tasks (typically "tasks" for spec-driven, check + status for others) 3. **Get apply instructions** @@ -43,20 +56,23 @@ Implement tasks from an OpenSpec change. ``` This returns: - - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) + - `contextFiles`: artifact ID -> array of concrete file paths (varies by + schema - could be proposal/specs/design/tasks or + spec/tests/implementation/docs) - Progress (total, complete, remaining) - Task list with status - Dynamic instruction based on current state **Handle states:** - - If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change + - If `state: "blocked"` (missing artifacts): show message, suggest using + openspec-continue-change - If `state: "all_done"`: congratulate, suggest archive - Otherwise: proceed to implementation 4. **Read context files** - Read every file path listed under `contextFiles` from the apply instructions output. - The files depend on the schema being used: + Read every file path listed under `contextFiles` from the apply instructions + output. The files depend on the schema being used: - **spec-driven**: proposal, specs, design, tasks - Other schemas: follow the contextFiles from CLI output @@ -143,6 +159,7 @@ What would you like to do? ``` **Guardrails** + - Keep going through tasks until done or blocked - Always read context files before starting (from the apply instructions output) - If task is ambiguous, pause and ask before implementing @@ -156,5 +173,7 @@ What would you like to do? This skill supports the "actions on a change" model: -- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions -- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly +- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), + after partial implementation, interleaved with other actions +- **Allows artifact updates**: If implementation reveals design issues, suggest + updating artifacts - not phase-locked, work fluidly diff --git a/.claude/skills/openspec-archive-change/SKILL.md b/.claude/skills/openspec-archive-change/SKILL.md index c0c169d..afaa203 100644 --- a/.claude/skills/openspec-archive-change/SKILL.md +++ b/.claude/skills/openspec-archive-change/SKILL.md @@ -12,20 +12,31 @@ metadata: Archive a completed change in the experimental workflow. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. - -**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. +**Store selection:** If the user names a store (a store is a standalone OpenSpec +repo registered on this machine) or the work lives in one, run +`openspec store list --json` to discover registered store ids, then pass +`--store ` on the commands that read or write specs and changes +(`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, +`doctor`, `context`). Other commands do not take the flag. Hints printed by +commands already carry the flag; keep it on follow-ups. Without a store, +commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name. If omitted, check if it can be +inferred from conversation context. If vague or ambiguous you MUST prompt for +available changes. **Steps** 1. **If no change name provided, prompt for selection** - Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select. + Run `openspec list --json` to get available changes. Use the + **AskUserQuestion tool** to let the user select. - Show only active changes (not already archived). - Include the schema used for each change if available. + Show only active changes (not already archived). Include the schema used for + each change if available. - **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user + choose. 2. **Check artifact completion status** @@ -33,7 +44,8 @@ Archive a completed change in the experimental workflow. Parse the JSON to understand: - `schemaName`: The workflow being used - - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path + and scope context - `artifacts`: List of artifacts with their status (`done` or other) **If any artifacts are not `done`:** @@ -56,22 +68,29 @@ Archive a completed change in the experimental workflow. 4. **Assess delta spec sync state** - Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt. + Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for + delta specs. If none exist, proceed without sync prompt. **If delta specs exist:** - - Compare each delta spec with its corresponding main spec at `openspec/specs//spec.md` - - Determine what changes would be applied (adds, modifications, removals, renames) + - Compare each delta spec with its corresponding main spec at + `openspec/specs//spec.md` + - Determine what changes would be applied (adds, modifications, removals, + renames) - Show a combined summary before prompting **Prompt options:** - If changes needed: "Sync now (recommended)", "Archive without syncing" - If already synced: "Archive now", "Sync anyway", "Cancel" - If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change ''. Delta spec analysis: "). Proceed to archive regardless of choice. + If user chooses sync, use Task tool (subagent_type: "general-purpose", + prompt: "Use Skill tool to invoke openspec-sync-specs for change ''. + Delta spec analysis: "). Proceed to + archive regardless of choice. 5. **Perform the archive** - Create an `archive` directory under `planningHome.changesDir` if it doesn't exist: + Create an `archive` directory under `planningHome.changesDir` if it doesn't + exist: ```bash mkdir -p "/archive" ``` @@ -79,7 +98,8 @@ Archive a completed change in the experimental workflow. Generate target name using current date: `YYYY-MM-DD-` **Check if target already exists:** - - If yes: Fail with error, suggest renaming existing archive or using different date + - If yes: Fail with error, suggest renaming existing archive or using + different date - If no: Move `changeRoot` to the archive directory ```bash @@ -109,10 +129,12 @@ All artifacts complete. All tasks complete. ``` **Guardrails** + - Always prompt for change selection if not provided - Use artifact graph (openspec status --json) for completion checking - Don't block archive on warnings - just inform and confirm - Preserve .openspec.yaml when moving to archive (it moves with the directory) - Show clear summary of what happened - If sync is requested, use openspec-sync-specs approach (agent-driven) -- If delta specs exist, always run the sync assessment and show the combined summary before prompting +- If delta specs exist, always run the sync assessment and show the combined + summary before prompting diff --git a/.claude/skills/openspec-explore/SKILL.md b/.claude/skills/openspec-explore/SKILL.md index 771271a..176394b 100644 --- a/.claude/skills/openspec-explore/SKILL.md +++ b/.claude/skills/openspec-explore/SKILL.md @@ -10,20 +10,38 @@ metadata: generatedBy: "1.6.0" --- -Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. - -**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. - -**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. - -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +Enter explore mode. Think deeply. Visualize freely. Follow the conversation +wherever it goes. + +**IMPORTANT: Explore mode is for thinking, not implementing.** You may read +files, search code, and investigate the codebase, but you must NEVER write code +or implement features. If the user asks you to implement something, remind them +to exit explore mode first and create a change proposal. You MAY create OpenSpec +artifacts (proposals, designs, specs) if the user asks—that's capturing +thinking, not implementing. + +**This is a stance, not a workflow.** There are no fixed steps, no required +sequence, no mandatory outputs. You're a thinking partner helping the user +explore. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec +repo registered on this machine) or the work lives in one, run +`openspec store list --json` to discover registered store ids, then pass +`--store ` on the commands that read or write specs and changes +(`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, +`doctor`, `context`). Other commands do not take the flag. Hints printed by +commands already carry the flag; keep it on follow-ups. Without a store, +commands act on the nearest local `openspec/` root. --- ## The Stance -- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script -- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions. +- **Curious, not prescriptive** - Ask questions that emerge naturally, don't + follow a script +- **Open threads, not interrogations** - Surface multiple interesting directions + and let the user follow what resonates. Don't funnel them through a single + path of questions. - **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking - **Adaptive** - Follow interesting threads, pivot when new information emerges - **Patient** - Don't rush to conclusions, let the shape of the problem emerge @@ -36,24 +54,28 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher Depending on what the user brings, you might: **Explore the problem space** + - Ask clarifying questions that emerge from what they said - Challenge assumptions - Reframe the problem - Find analogies **Investigate the codebase** + - Map existing architecture relevant to the discussion - Find integration points - Identify patterns already in use - Surface hidden complexity **Compare options** + - Brainstorm multiple approaches - Build comparison tables - Sketch tradeoffs - Recommend a path (if asked) **Visualize** + ``` ┌─────────────────────────────────────────┐ │ Use ASCII diagrams liberally │ @@ -72,6 +94,7 @@ Depending on what the user brings, you might: ``` **Surface risks and unknowns** + - Identify what could go wrong - Find gaps in understanding - Suggest spikes or investigations @@ -85,11 +108,13 @@ You have full context of the OpenSpec system. Use it naturally, don't force it. ### Check for context At the start, quickly check what exists: + ```bash openspec list --json ``` This tells you: + - If there are active changes - Their names, schemas, and status - What the user might be working on @@ -107,23 +132,26 @@ If the user mentions a change or you detect one is relevant: 1. **Resolve and read existing artifacts for context** - Run `openspec status --change "" --json`. - - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON. + - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status + JSON. - Read existing files from `artifactPaths..existingOutputPaths`. 2. **Reference them naturally in conversation** - - "Your design mentions using Redis, but we just realized SQLite fits better..." - - "The proposal scopes this to premium users, but we're now thinking everyone..." + - "Your design mentions using Redis, but we just realized SQLite fits + better..." + - "The proposal scopes this to premium users, but we're now thinking + everyone..." 3. **Offer to capture when decisions are made** - | Insight Type | Where to Capture | - |----------------------------|--------------------------------| - | New requirement discovered | `specs//spec.md` | - | Requirement changed | `specs//spec.md` | - | Design decision made | `design.md` | - | Scope changed | `proposal.md` | - | New work identified | `tasks.md` | - | Assumption invalidated | Relevant artifact | + | Insight Type | Where to Capture | + | -------------------------- | ---------------------------- | + | New requirement discovered | `specs//spec.md` | + | Requirement changed | `specs//spec.md` | + | Design decision made | `design.md` | + | Scope changed | `proposal.md` | + | New work identified | `tasks.md` | + | Assumption invalidated | Relevant artifact | Example offers: - "That's a design decision. Capture it in design.md?" @@ -148,6 +176,7 @@ If the user mentions a change or you detect one is relevant: ## Handling Different Entry Points **User brings a vague idea:** + ``` User: I'm thinking about adding real-time collaboration @@ -171,6 +200,7 @@ You: Real-time collab is a big space. Let me think about this... ``` **User brings a specific problem:** + ``` User: The auth system is a mess @@ -202,6 +232,7 @@ You: [reads codebase] ``` **User is stuck mid-implementation:** + ``` User: /opsx:explore add-auth-system The OAuth integration is more complex than expected @@ -219,6 +250,7 @@ You: [reads change artifacts] ``` **User wants to compare options:** + ``` User: Should we use Postgres or SQLite? @@ -280,7 +312,8 @@ But this summary is optional. Sometimes the thinking IS the value. ## Guardrails -- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not. +- **Don't implement** - Never write code or implement features. Creating + OpenSpec artifacts is fine, writing application code is not. - **Don't fake understanding** - If something is unclear, dig deeper - **Don't rush** - Discovery is thinking time, not task time - **Don't force structure** - Let patterns emerge naturally diff --git a/.claude/skills/openspec-new-change/SKILL.md b/.claude/skills/openspec-new-change/SKILL.md index b4d8a9e..83e7a92 100644 --- a/.claude/skills/openspec-new-change/SKILL.md +++ b/.claude/skills/openspec-new-change/SKILL.md @@ -12,28 +12,41 @@ metadata: Start a new change using the experimental artifact-driven approach. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. - -**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. +**Store selection:** If the user names a store (a store is a standalone OpenSpec +repo registered on this machine) or the work lives in one, run +`openspec store list --json` to discover registered store ids, then pass +`--store ` on the commands that read or write specs and changes +(`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, +`doctor`, `context`). Other commands do not take the flag. Hints printed by +commands already carry the flag; keep it on follow-ups. Without a store, +commands act on the nearest local `openspec/` root. + +**Input**: The user's request should include a change name (kebab-case) OR a +description of what they want to build. **Steps** 1. **If no clear input provided, ask what they want to build** Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: - > "What change do you want to work on? Describe what you want to build or fix." + > "What change do you want to work on? Describe what you want to build or + > fix." - From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`). + From their description, derive a kebab-case name (e.g., "add user + authentication" → `add-user-auth`). - **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. + **IMPORTANT**: Do NOT proceed without understanding what the user wants to + build. 2. **Determine the workflow schema** - Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow. + Use the default schema (omit `--schema`) unless the user explicitly requests + a different workflow. **Use a different schema only if the user mentions:** - A specific schema name → use `--schema ` - - "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose + - "show workflows" or "what workflows" → run `openspec schemas --json` and + let them choose **Otherwise**: Omit `--schema` to use the default. @@ -41,18 +54,19 @@ Start a new change using the experimental artifact-driven approach. ```bash openspec new change "" ``` - Add `--schema ` only if the user requested a specific workflow. - This creates a scaffolded change in the planning home resolved by the CLI. + Add `--schema ` only if the user requested a specific workflow. This + creates a scaffolded change in the planning home resolved by the CLI. 4. **Show the artifact status** ```bash openspec status --change "" --json ``` - Use the returned `planningHome`, `changeRoot`, `artifactPaths`, and `nextSteps` instead of assuming repo-local paths. + Use the returned `planningHome`, `changeRoot`, `artifactPaths`, and + `nextSteps` instead of assuming repo-local paths. -5. **Get instructions for the first artifact** - The first artifact depends on the schema (e.g., `proposal` for spec-driven). - Check the status output to find the first artifact with status "ready". +5. **Get instructions for the first artifact** The first artifact depends on the + schema (e.g., `proposal` for spec-driven). Check the status output to find + the first artifact with status "ready". ```bash openspec instructions --change "" ``` @@ -63,15 +77,19 @@ Start a new change using the experimental artifact-driven approach. **Output** After completing the steps, summarize: + - Change name and location - Schema/workflow being used and its artifact sequence - Current status (0/N artifacts complete) - The template for the first artifact -- Prompt: "Ready to create the first artifact? Just describe what this change is about and I'll draft it, or ask me to continue." +- Prompt: "Ready to create the first artifact? Just describe what this change is + about and I'll draft it, or ask me to continue." **Guardrails** + - Do NOT create any artifacts yet - just show the instructions - Do NOT advance beyond showing the first artifact template - If the name is invalid (not kebab-case), ask for a valid name -- If a change with that name already exists, suggest continuing that change instead +- If a change with that name already exists, suggest continuing that change + instead - Pass --schema if using a non-default workflow diff --git a/.claude/skills/openspec-propose/SKILL.md b/.claude/skills/openspec-propose/SKILL.md index 716d2d3..5c97035 100644 --- a/.claude/skills/openspec-propose/SKILL.md +++ b/.claude/skills/openspec-propose/SKILL.md @@ -13,6 +13,7 @@ metadata: Propose a new change - create the change and generate all artifacts in one step. I'll create a change with artifacts: + - proposal.md (what & why) - design.md (how) - tasks.md (implementation steps) @@ -21,67 +22,88 @@ When ready to implement, run /opsx:apply --- -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Store selection:** If the user names a store (a store is a standalone OpenSpec +repo registered on this machine) or the work lives in one, run +`openspec store list --json` to discover registered store ids, then pass +`--store ` on the commands that read or write specs and changes +(`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, +`doctor`, `context`). Other commands do not take the flag. Hints printed by +commands already carry the flag; keep it on follow-ups. Without a store, +commands act on the nearest local `openspec/` root. -**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. +**Input**: The user's request should include a change name (kebab-case) OR a +description of what they want to build. **Steps** 1. **If no clear input provided, ask what they want to build** Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: - > "What change do you want to work on? Describe what you want to build or fix." + > "What change do you want to work on? Describe what you want to build or + > fix." - From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`). + From their description, derive a kebab-case name (e.g., "add user + authentication" → `add-user-auth`). - **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. + **IMPORTANT**: Do NOT proceed without understanding what the user wants to + build. 2. **Create the change directory** ```bash openspec new change "" ``` - This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. + This creates a scaffolded change in the planning home resolved by the CLI + with `.openspec.yaml`. 3. **Get the artifact build order** ```bash openspec status --change "" --json ``` Parse the JSON to get: - - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) + - `applyRequires`: array of artifact IDs needed before implementation (e.g., + `["tasks"]`) - `artifacts`: list of all artifacts with their status and dependencies - - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path + and scope context. Use these instead of assuming repo-local paths. 4. **Create artifacts in sequence until apply-ready** Use the **TodoWrite tool** to track progress through the artifacts. - Loop through artifacts in dependency order (artifacts with no pending dependencies first): + Loop through artifacts in dependency order (artifacts with no pending + dependencies first): a. **For each artifact that is `ready` (dependencies satisfied)**: - - Get instructions: - ```bash - openspec instructions --change "" --json - ``` - - The instructions JSON includes: - - `context`: Project background (constraints for you - do NOT include in output) - - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) - - `template`: The structure to use for your output file - - `instruction`: Schema-specific guidance for this artifact type - - `resolvedOutputPath`: Resolved path or pattern to write the artifact - - `dependencies`: Completed artifacts to read for context - - Read any completed dependency files for context - - Create the artifact file using `template` as the structure and write it to `resolvedOutputPath` - - Apply `context` and `rules` as constraints - but do NOT copy them into the file - - Show brief progress: "Created " + - Get instructions: + ```bash + openspec instructions --change "" --json + ``` + - The instructions JSON includes: + - `context`: Project background (constraints for you - do NOT include in + output) + - `rules`: Artifact-specific rules (constraints for you - do NOT include in + output) + - `template`: The structure to use for your output file + - `instruction`: Schema-specific guidance for this artifact type + - `resolvedOutputPath`: Resolved path or pattern to write the artifact + - `dependencies`: Completed artifacts to read for context + - Read any completed dependency files for context + - Create the artifact file using `template` as the structure and write it to + `resolvedOutputPath` + - Apply `context` and `rules` as constraints - but do NOT copy them into the + file + - Show brief progress: "Created " b. **Continue until all `applyRequires` artifacts are complete** - - After creating each artifact, re-run `openspec status --change "" --json` - - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array - - Stop when all `applyRequires` artifacts are done + - After creating each artifact, re-run + `openspec status --change "" --json` + - Check if every artifact ID in `applyRequires` has `status: "done"` in the + artifacts array + - Stop when all `applyRequires` artifacts are done c. **If an artifact requires user input** (unclear context): - - Use **AskUserQuestion tool** to clarify - - Then continue with creation + - Use **AskUserQuestion tool** to clarify + - Then continue with creation 5. **Show final status** ```bash @@ -91,24 +113,33 @@ When ready to implement, run /opsx:apply **Output** After completing all artifacts, summarize: + - Change name and location - List of artifacts created with brief descriptions - What's ready: "All artifacts created! Ready for implementation." -- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks." +- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the + tasks." **Artifact Creation Guidelines** -- Follow the `instruction` field from `openspec instructions` for each artifact type +- Follow the `instruction` field from `openspec instructions` for each artifact + type - The schema defines what each artifact should contain - follow it - Read dependency artifacts for context before creating new ones - Use `template` as the structure for your output file - fill in its sections -- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file - - Do NOT copy ``, ``, `` blocks into the artifact +- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for + the file + - Do NOT copy ``, ``, `` blocks into the + artifact - These guide what you write, but should never appear in the output **Guardrails** -- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`) + +- Create ALL artifacts needed for implementation (as defined by schema's + `apply.requires`) - Always read dependency artifacts before creating a new one -- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum -- If a change with that name already exists, ask if user wants to continue it or create a new one +- If context is critically unclear, ask the user - but prefer making reasonable + decisions to keep momentum +- If a change with that name already exists, ask if user wants to continue it or + create a new one - Verify each artifact file exists after writing before proceeding to next diff --git a/.claude/skills/openspec-verify-change/SKILL.md b/.claude/skills/openspec-verify-change/SKILL.md index b6557c6..4252a78 100644 --- a/.claude/skills/openspec-verify-change/SKILL.md +++ b/.claude/skills/openspec-verify-change/SKILL.md @@ -10,23 +10,35 @@ metadata: generatedBy: "1.6.0" --- -Verify that an implementation matches the change artifacts (specs, tasks, design). - -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. - -**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. +Verify that an implementation matches the change artifacts (specs, tasks, +design). + +**Store selection:** If the user names a store (a store is a standalone OpenSpec +repo registered on this machine) or the work lives in one, run +`openspec store list --json` to discover registered store ids, then pass +`--store ` on the commands that read or write specs and changes +(`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, +`doctor`, `context`). Other commands do not take the flag. Hints printed by +commands already carry the flag; keep it on follow-ups. Without a store, +commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name. If omitted, check if it can be +inferred from conversation context. If vague or ambiguous you MUST prompt for +available changes. **Steps** 1. **If no change name provided, prompt for selection** - Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select. + Run `openspec list --json` to get available changes. Use the + **AskUserQuestion tool** to let the user select. - Show changes that have implementation tasks (tasks artifact exists). - Include the schema used for each change if available. - Mark changes with incomplete tasks as "(In Progress)". + Show changes that have implementation tasks (tasks artifact exists). Include + the schema used for each change if available. Mark changes with incomplete + tasks as "(In Progress)". - **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user + choose. 2. **Check status to understand the schema** ```bash @@ -34,7 +46,8 @@ Verify that an implementation matches the change artifacts (specs, tasks, design ``` Parse the JSON to understand: - `schemaName`: The workflow being used (e.g., "spec-driven") - - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path + and scope context - Which artifacts exist for this change 3. **Get planning context and load artifacts** @@ -43,7 +56,8 @@ Verify that an implementation matches the change artifacts (specs, tasks, design openspec instructions apply --change "" --json ``` - This returns the change directory and `contextFiles` (artifact ID -> array of concrete file paths). Read all available artifacts from `contextFiles`. + This returns the change directory and `contextFiles` (artifact ID -> array of + concrete file paths). Read all available artifacts from `contextFiles`. 4. **Initialize verification report structure** @@ -62,7 +76,8 @@ Verify that an implementation matches the change artifacts (specs, tasks, design - Count complete vs total tasks - If incomplete tasks exist: - Add CRITICAL issue for each incomplete task - - Recommendation: "Complete task: " or "Mark as done if already implemented" + - Recommendation: "Complete task: " or "Mark as done if + already implemented" **Spec Coverage**: - If delta specs exist in `contextFiles.specs`: @@ -91,18 +106,22 @@ Verify that an implementation matches the change artifacts (specs, tasks, design - Check if tests exist covering the scenario - If scenario appears uncovered: - Add WARNING: "Scenario not covered: " - - Recommendation: "Add test or implementation for scenario: " + - Recommendation: "Add test or implementation for scenario: + " 7. **Verify Coherence** **Design Adherence**: - If `contextFiles.design` exists: - - Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:") + - Extract key decisions (look for sections like "Decision:", "Approach:", + "Architecture:") - Verify implementation follows those decisions - If contradiction detected: - Add WARNING: "Design decision not followed: " - - Recommendation: "Update implementation or revise design.md to match reality" - - If no design.md: Skip design adherence check, note "No design.md to verify against" + - Recommendation: "Update implementation or revise design.md to match + reality" + - If no design.md: Skip design adherence check, note "No design.md to verify + against" **Code Pattern Consistency**: - Review new code for consistency with project patterns @@ -144,16 +163,21 @@ Verify that an implementation matches the change artifacts (specs, tasks, design **Final Assessment**: - If CRITICAL issues: "X critical issue(s) found. Fix before archiving." - - If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)." + - If only warnings: "No critical issues. Y warning(s) to consider. Ready for + archive (with noted improvements)." - If all clear: "All checks passed. Ready for archive." **Verification Heuristics** -- **Completeness**: Focus on objective checklist items (checkboxes, requirements list) -- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty +- **Completeness**: Focus on objective checklist items (checkboxes, requirements + list) +- **Correctness**: Use keyword search, file path analysis, reasonable + inference - don't require perfect certainty - **Coherence**: Look for glaring inconsistencies, don't nitpick style -- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL -- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable +- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING + over CRITICAL +- **Actionability**: Every issue must have a specific recommendation with + file/line references where applicable **Graceful Degradation** @@ -165,6 +189,7 @@ Verify that an implementation matches the change artifacts (specs, tasks, design **Output Format** Use clear markdown with: + - Table for summary scorecard - Grouped lists for issues (CRITICAL/WARNING/SUGGESTION) - Code references in format: `file.ts:123` diff --git a/DESIGN.md b/DESIGN.md index a1047ce..f4acf23 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,18 +1,18 @@ # Design -Visual system for Quantum. Register: product. Personality: calm precision — -ink on good paper, composed and legible. All colors OKLCH. +Visual system for Quantum. Register: product. Personality: calm precision — ink +on good paper, composed and legible. All colors OKLCH. ## Theme -Scene: a couple reviews the month on a laptop at the kitchen table under -evening lamplight; each also glances at balances on a phone in daylight. Both -themes are first-class: **light is the canonical "paper statement" reading, -dark is the evening companion**. Default follows `prefers-color-scheme`, with a -`data-theme` override on `` persisted per user. +Scene: a couple reviews the month on a laptop at the kitchen table under evening +lamplight; each also glances at balances on a phone in daylight. Both themes are +first-class: **light is the canonical "paper statement" reading, dark is the +evening companion**. Default follows `prefers-color-scheme`, with a `data-theme` +override on `` persisted per user. -Identity holds across themes: warm ink on warm paper (light), warm chalk on -warm charcoal (dark). Never pure black or white. +Identity holds across themes: warm ink on warm paper (light), warm chalk on warm +charcoal (dark). Never pure black or white. ## Color @@ -22,34 +22,34 @@ data demands them. ### Neutrals (warm, hue ≈ 85) -| Token | Light | Dark | -|---|---|---| -| `--bg` | `oklch(0.975 0.004 85)` | `oklch(0.19 0.006 85)` | -| `--bg-raised` | `oklch(0.99 0.003 85)` | `oklch(0.225 0.007 85)` | -| `--bg-sunken` | `oklch(0.955 0.005 85)` | `oklch(0.165 0.006 85)` | -| `--text` | `oklch(0.245 0.012 85)` | `oklch(0.92 0.008 85)` | -| `--text-muted` | `oklch(0.49 0.012 85)` | `oklch(0.70 0.01 85)` | -| `--text-faint` | `oklch(0.62 0.01 85)` | `oklch(0.55 0.01 85)` | -| `--border` | `oklch(0.90 0.006 85)` | `oklch(0.30 0.008 85)` | -| `--border-strong` | `oklch(0.82 0.008 85)` | `oklch(0.38 0.01 85)` | +| Token | Light | Dark | +| ----------------- | ----------------------- | ----------------------- | +| `--bg` | `oklch(0.975 0.004 85)` | `oklch(0.19 0.006 85)` | +| `--bg-raised` | `oklch(0.99 0.003 85)` | `oklch(0.225 0.007 85)` | +| `--bg-sunken` | `oklch(0.955 0.005 85)` | `oklch(0.165 0.006 85)` | +| `--text` | `oklch(0.245 0.012 85)` | `oklch(0.92 0.008 85)` | +| `--text-muted` | `oklch(0.49 0.012 85)` | `oklch(0.70 0.01 85)` | +| `--text-faint` | `oklch(0.62 0.01 85)` | `oklch(0.55 0.01 85)` | +| `--border` | `oklch(0.90 0.006 85)` | `oklch(0.30 0.008 85)` | +| `--border-strong` | `oklch(0.82 0.008 85)` | `oklch(0.38 0.01 85)` | ### Accent — spruce (hue ≈ 190, deliberately not money-green, not fintech navy) -| Token | Light | Dark | -|---|---|---| -| `--accent` | `oklch(0.46 0.07 190)` | `oklch(0.80 0.08 185)` | +| Token | Light | Dark | +| ---------------- | ----------------------- | ----------------------- | +| `--accent` | `oklch(0.46 0.07 190)` | `oklch(0.80 0.08 185)` | | `--accent-hover` | `oklch(0.40 0.075 190)` | `oklch(0.85 0.075 185)` | -| `--accent-bg` | `oklch(0.94 0.02 190)` | `oklch(0.27 0.03 190)` | -| `--on-accent` | `oklch(0.98 0.005 190)` | `oklch(0.20 0.02 190)` | +| `--accent-bg` | `oklch(0.94 0.02 190)` | `oklch(0.27 0.03 190)` | +| `--on-accent` | `oklch(0.98 0.005 190)` | `oklch(0.20 0.02 190)` | ### Semantic (paired with sign/shape, never hue alone) -| Token | Light | Dark | Use | -|---|---|---|---| -| `--pos` | `oklch(0.52 0.10 175)` | `oklch(0.78 0.11 170)` | income, inflow | -| `--neg` | `oklch(0.53 0.13 30)` | `oklch(0.74 0.13 30)` | expense, outflow, destructive | -| `--warn` | `oklch(0.60 0.11 75)` | `oklch(0.80 0.11 80)` | staleness, needs-attention | -| `--warn-bg` | `oklch(0.95 0.03 85)` | `oklch(0.26 0.03 80)` | banner fills | +| Token | Light | Dark | Use | +| ----------- | ---------------------- | ---------------------- | ----------------------------- | +| `--pos` | `oklch(0.52 0.10 175)` | `oklch(0.78 0.11 170)` | income, inflow | +| `--neg` | `oklch(0.53 0.13 30)` | `oklch(0.74 0.13 30)` | expense, outflow, destructive | +| `--warn` | `oklch(0.60 0.11 75)` | `oklch(0.80 0.11 80)` | staleness, needs-attention | +| `--warn-bg` | `oklch(0.95 0.03 85)` | `oklch(0.26 0.03 80)` | banner fills | Red (`--neg`) appears in chrome only when money or data is at risk; an expense figure in a table is plain ink, not red. @@ -63,26 +63,26 @@ figure in a table is plain ink, not red. palette index (`categories.color_index`, assigned at creation, never recomputed from position). Generated with [poline](https://github.com/meodai/poline) via - `scripts/generate-category-palette.ts` — a closed-loop hue journey anchored - at spruce/amber/plum, normalized into OKLCH L 0.54–0.62, C ≥ 0.10 so one set - passes the lightness band, chroma floor, and 3:1 contrast on BOTH surfaces - (no dark override). Neighboring slots are interleaved to maximize hue - distance; charts additionally separate fills with 2px surface gaps and - always carry a legend, so identity never rests on hue alone. Neutral - (`--text-faint`) is reserved for "Other" and "Uncategorized" — never a - category. + `scripts/generate-category-palette.ts` — a closed-loop hue journey anchored at + spruce/amber/plum, normalized into OKLCH L 0.54–0.62, C ≥ 0.10 so one set + passes the lightness band, chroma floor, and 3:1 contrast on BOTH surfaces (no + dark override). Neighboring slots are interleaved to maximize hue distance; + charts additionally separate fills with 2px surface gaps and always carry a + legend, so identity never rests on hue alone. Neutral (`--text-faint`) is + reserved for "Other" and "Uncategorized" — never a category. ## Typography - **UI + numerals**: Inter Variable (bundled via `@fontsource-variable/inter`, self-hosted, no CDN). Fallback `system-ui`. -- **Monospace** (DIDs, rule patterns, tokens): `ui-monospace, 'Cascadia Mono', +- **Monospace** (DIDs, rule patterns, tokens): + `ui-monospace, 'Cascadia Mono', Consolas, monospace`. - **Tabular figures are law**: any column of money or dates sets `font-variant-numeric: tabular-nums`. - Scale (1.25 ratio): 13 / 14 (body) / 16 / 20 / 25 / 31 px as - `--text-xs/-sm/-md/-lg/-xl/-2xl`. Weights: 400 body, 500 emphasis & nav, - 600 headings & figures. Line height 1.5 body, 1.2 headings. + `--text-xs/-sm/-md/-lg/-xl/-2xl`. Weights: 400 body, 500 emphasis & nav, 600 + headings & figures. Line height 1.5 body, 1.2 headings. - Body measure ≤ 70ch. Money aligns right; descriptions align left. ## Spacing, shape, elevation @@ -91,8 +91,8 @@ figure in a table is plain ink, not red. - Radius: `--radius-sm` 5px (inputs, chips), `--radius-md` 8px (surfaces, menus), full for provenance badges. - Elevation by border first: 1px `--border` + `--bg-raised`. Shadows only on - overlays (menus, dialogs): `0 4px 16px oklch(0 0 0 / 0.10)` light, - `/ 0.35` dark. No decorative shadows on static content. + overlays (menus, dialogs): `0 4px 16px oklch(0 0 0 / 0.10)` light, `/ 0.35` + dark. No decorative shadows on static content. ## Motion @@ -102,10 +102,10 @@ property animation. `prefers-reduced-motion: reduce` collapses all to 0ms. ## App shell -- **Desktop (≥880px)**: fixed left sidebar, 216px. Wordmark "Quantum" top; - nav: Dashboard, Ledger, Reports, Accounts, Settings; footer shows sync - status line ("Synced 6:02 AM") and the signed-in user's handle. Content area - scrolls independently; reports/forms cap at 1040px, the ledger runs fluid. +- **Desktop (≥880px)**: fixed left sidebar, 216px. Wordmark "Quantum" top; nav: + Dashboard, Ledger, Reports, Accounts, Settings; footer shows sync status line + ("Synced 6:02 AM") and the signed-in user's handle. Content area scrolls + independently; reports/forms cap at 1040px, the ledger runs fluid. - **Mobile (<880px)**: bottom tab bar with the same five destinations; page title in a slim top bar. No hamburger menus. - Login is shell-less: a centered column on `--bg`, wordmark, one input, one @@ -116,8 +116,8 @@ property animation. `prefers-reduced-motion: reduce` collapses all to 0ms. - **Provenance badge**: pill, `--bg-sunken` fill, 12px text: `⚙ rule` / `person's handle` / `↻ carried`. Click opens event history. - **Banners** (connection health): full-width strip under the top of content, - `--warn-bg` fill, 1px `--warn` border (all four sides), plain sentence + - one link out. No icons larger than the text. + `--warn-bg` fill, 1px `--warn` border (all four sides), plain sentence + one + link out. No icons larger than the text. - **Tables**: row hover `--bg-sunken`, 1px hairline row separators, header 13px/500/`--text-muted`, uppercase avoided. - **Empty states**: one sentence + one action, set in `--text-muted`. No diff --git a/PRODUCT.md b/PRODUCT.md index 72c65f0..e38e862 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -17,8 +17,8 @@ archiving an email. Quantum is a self-hosted, Mint-style reporting tool fed by SimpleFIN bank data. It answers two questions: "where did our money go this month?" (income vs. -expense by category) and "are we gaining ground?" (net worth over time). It is -a mirror and a ledger, not a budgeting coach: it never nags, never celebrates, +expense by category) and "are we gaining ground?" (net worth over time). It is a +mirror and a ledger, not a budgeting coach: it never nags, never celebrates, never advises. Success = both partners trust the numbers and can find the story behind any figure (every categorization traces to a rule or a person). @@ -40,20 +40,20 @@ approachable categories and charts, without its consumer gloss. ## Design Principles 1. **The number is the interface.** Typography and alignment do the work; - decoration never competes with a figure. Tabular numerals everywhere money - or dates column up. + decoration never competes with a figure. Tabular numerals everywhere money or + dates column up. 2. **Provenance is a first-class citizen.** Who or what categorized a transaction is always one glance away, never buried in a detail view. -3. **Calm states, honest states.** Errors (a stale bank connection, an - unclaimed token) are stated plainly with the next action, not dressed up or - alarmed. No red unless money or data is actually at risk. -4. **Two-person software.** Attribution, requests, and shared state are - designed for exactly two known people; nothing generalizes to "teams". +3. **Calm states, honest states.** Errors (a stale bank connection, an unclaimed + token) are stated plainly with the next action, not dressed up or alarmed. No + red unless money or data is actually at risk. +4. **Two-person software.** Attribution, requests, and shared state are designed + for exactly two known people; nothing generalizes to "teams". 5. **Phone glance, laptop session.** Every surface has a legible narrow composition; density increases with width instead of shrinking to fit. ## Accessibility & Inclusion Sensible defaults, unaudited: WCAG AA contrast targets, full keyboard -navigability, `prefers-reduced-motion` respected, color-blind-safe chart -palette (never encode income/expense by hue alone; pair with sign and shape). +navigability, `prefers-reduced-motion` respected, color-blind-safe chart palette +(never encode income/expense by hue alone; pair with sign and shape). diff --git a/README.md b/README.md index 1b88c6d..bc381e4 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,12 @@ expense per category and net worth over time. 2. **Configure the environment** — copy `.env.example` to `.env` and fill in: - | Variable | Meaning | - |---|---| - | `APP_URL` | Public HTTPS origin, e.g. `https://quantum.example.com`. Drives the OAuth client identity: changing it later forces both users to re-consent. | - | `ALLOWED_DIDS` | Comma-separated DIDs allowed to log in (find yours at [internect.info](https://internect.info)). Everyone else gets a 403. | - | `DB_PATH` | SQLite file path, e.g. `./data/quantum.db`. | - | `OAUTH_PRIVATE_KEY_JWK` | Output of `deno task generate-key`, single line. | + | Variable | Meaning | + | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | + | `APP_URL` | Public HTTPS origin, e.g. `https://quantum.example.com`. Drives the OAuth client identity: changing it later forces both users to re-consent. | + | `ALLOWED_DIDS` | Comma-separated DIDs allowed to log in (find yours at [internect.info](https://internect.info)). Everyone else gets a 403. | + | `DB_PATH` | SQLite file path, e.g. `./data/quantum.db`. | + | `OAUTH_PRIVATE_KEY_JWK` | Output of `deno task generate-key`, single line. | 3. **Caddy route** — plain reverse proxy, no special paths: @@ -58,8 +58,8 @@ expense per category and net worth over time. The container reads `APP_URL`, `ALLOWED_DIDS`, and `OAUTH_PRIVATE_KEY_JWK` from the environment and stores the database on the `quantum-data` named - volume (`DB_PATH` defaults to `/data/quantum.db` inside). Back up that - volume — it is the only state. To start on boot, generate a systemd unit + volume (`DB_PATH` defaults to `/data/quantum.db` inside). Back up that volume + — it is the only state. To start on boot, generate a systemd unit (`podman generate systemd --new quantum`) or use a Quadlet. Or directly on the host: @@ -74,14 +74,14 @@ expense per category and net worth over time. host, run from the repo root (the `migrations/` directory and `.env` are resolved relative to it). -5. **First run** — log in with an allowlisted handle, open **Settings**, paste - a SimpleFIN setup token (single-use; generate it at the Bridge), and the - first sync backfills what your banks provide (typically ~90 days). New - accounts land in **Accounts → Needs setup** for one-time classification. +5. **First run** — log in with an allowlisted handle, open **Settings**, paste a + SimpleFIN setup token (single-use; generate it at the Bridge), and the first + sync backfills what your banks provide (typically ~90 days). New accounts + land in **Accounts → Needs setup** for one-time classification. -Day-to-day: a daily sync runs automatically (`Deno.cron`); **Sync now** lives -in Settings. Bank connections are managed at the Bridge, never in the app — -Quantum surfaces connection problems and links you there. +Day-to-day: a daily sync runs automatically (`Deno.cron`); **Sync now** lives in +Settings. Bank connections are managed at the Bridge, never in the app — Quantum +surfaces connection problems and links you there. ## Development @@ -108,15 +108,15 @@ writable by it: ## Operational notes -- **The database file is secret-grade.** It contains the SimpleFIN Access URL - (a permanent credential for read-only bank access) and your full financial +- **The database file is secret-grade.** It contains the SimpleFIN Access URL (a + permanent credential for read-only bank access) and your full financial history. Restrict file permissions and treat backups with the same care. - **Back up `DB_PATH`** (plus its `-wal`/`-shm` siblings, or use `sqlite3 .backup`). Balance history powers the net-worth chart and cannot be re-fetched: losing the file loses it. Under Docker that means the `quantum-data` volume. -- Transaction backfill is limited to what banks return at first sync - (~90 days). Balance snapshots start on day one and only grow. +- Transaction backfill is limited to what banks return at first sync (~90 days). + Balance snapshots start on day one and only grow. - All money is stored as integer cents; 2-decimal currencies are assumed. - Raw SimpleFIN responses are archived verbatim in `raw_syncs` — normalization can be replayed against history if the schema evolves. diff --git a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/design.md b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/design.md index 9acf62d..1792947 100644 --- a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/design.md +++ b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/design.md @@ -1,97 +1,230 @@ ## Context -Greenfield repo. Requirements settled during exploration: a Mint-style (reporting-first) household finance app for exactly two users, self-hosted at home behind an existing Caddy reverse proxy, using SimpleFIN as the sole bank-data source and AT Protocol OAuth for identity. Hard constraints: Deno 2.9 and SvelteKit. +Greenfield repo. Requirements settled during exploration: a Mint-style +(reporting-first) household finance app for exactly two users, self-hosted at +home behind an existing Caddy reverse proxy, using SimpleFIN as the sole +bank-data source and AT Protocol OAuth for identity. Hard constraints: Deno 2.9 +and SvelteKit. Key external facts that shaped the design: -- **SimpleFIN** is pull-only and read-only. A one-time setup token is claimed for a permanent Access URL (Basic Auth baked in). `GET {access_url}/accounts` returns everything; the Bridge refreshes bank data roughly daily, so polling faster is pointless. There are no webhooks, no categories, no account types, and no dedup guarantee across the pending→posted transition (a pending transaction may re-post under a different id). -- **ATProto OAuth** requires the `client_id` to be a publicly fetchable HTTPS URL serving a client-metadata JSON document; DPoP and PAR are mandatory; confidential clients authenticate with `private_key_jwt` and a published JWKS. Consequence: the home server must have one public HTTPS origin. +- **SimpleFIN** is pull-only and read-only. A one-time setup token is claimed + for a permanent Access URL (Basic Auth baked in). `GET {access_url}/accounts` + returns everything; the Bridge refreshes bank data roughly daily, so polling + faster is pointless. There are no webhooks, no categories, no account types, + and no dedup guarantee across the pending→posted transition (a pending + transaction may re-post under a different id). +- **ATProto OAuth** requires the `client_id` to be a publicly fetchable HTTPS + URL serving a client-metadata JSON document; DPoP and PAR are mandatory; + confidential clients authenticate with `private_key_jwt` and a published JWKS. + Consequence: the home server must have one public HTTPS origin. ## Goals / Non-Goals **Goals:** -- Ship a complete vertical slice: login → connect SimpleFIN → daily sync → categorize → monthly category totals + net worth chart. -- Preserve every byte SimpleFIN ever sends (raw archive) so future visualizations and normalization changes can replay history. -- Make every categorization explainable: which rule fired, or which person acted, and when. -- Keep deferred features (category groups, request-partner-to-categorize, payee normalization) cheap to add later via schema choices made now. +- Ship a complete vertical slice: login → connect SimpleFIN → daily sync → + categorize → monthly category totals + net worth chart. +- Preserve every byte SimpleFIN ever sends (raw archive) so future + visualizations and normalization changes can replay history. +- Make every categorization explainable: which rule fired, or which person + acted, and when. +- Keep deferred features (category groups, request-partner-to-categorize, payee + normalization) cheap to add later via schema choices made now. **Non-Goals:** -- Budgeting (envelopes, targets, caps) — this is reporting-first; budgets are a possible later change. -- Multi-household/tenant support, roles, or any user management beyond the two-DID allowlist. -- Writing anything back to banks or SimpleFIN; the app is a read-only mirror plus local annotations. -- Native desktop/mobile apps, offline mode, or non-SimpleFIN import (CSV/OFX) in v1 — but the architecture MUST keep the frontend/backend boundary separable so future native clients (e.g., Deno desktop) can target a standalone Quantum backend (see D6). +- Budgeting (envelopes, targets, caps) — this is reporting-first; budgets are a + possible later change. +- Multi-household/tenant support, roles, or any user management beyond the + two-DID allowlist. +- Writing anything back to banks or SimpleFIN; the app is a read-only mirror + plus local annotations. +- Native desktop/mobile apps, offline mode, or non-SimpleFIN import (CSV/OFX) in + v1 — but the architecture MUST keep the frontend/backend boundary separable so + future native clients (e.g., Deno desktop) can target a standalone Quantum + backend (see D6). ## Decisions ### D1. Runtime & persistence: SvelteKit on Deno 2.9, SQLite via `node:sqlite` -SvelteKit runs under Deno via npm compat; server-only modules host the sync engine and auth. SQLite through the built-in `node:sqlite` means zero native dependencies and a single-file database that is trivial to back up. *Alternatives*: Deno KV (no relational queries, weaker reporting story); Postgres (a server process to babysit for a two-user app — unjustified). - -- Amounts are stored as **integer cents**, converted exactly once at ingestion from SimpleFIN's numeric strings. Floats never touch money. -- **Numbered SQL migrations** (`migrations/NNN_*.sql`) applied at startup, tracked in a `schema_version` table. The database will hold years of irreplaceable categorization and balance history; schema evolution is a first-class concern. -- Scheduling via **`Deno.cron`** in the server process (daily sync), with a manual "sync now" action in the UI. +SvelteKit runs under Deno via npm compat; server-only modules host the sync +engine and auth. SQLite through the built-in `node:sqlite` means zero native +dependencies and a single-file database that is trivial to back up. +_Alternatives_: Deno KV (no relational queries, weaker reporting story); +Postgres (a server process to babysit for a two-user app — unjustified). + +- Amounts are stored as **integer cents**, converted exactly once at ingestion + from SimpleFIN's numeric strings. Floats never touch money. +- **Numbered SQL migrations** (`migrations/NNN_*.sql`) applied at startup, + tracked in a `schema_version` table. The database will hold years of + irreplaceable categorization and balance history; schema evolution is a + first-class concern. +- Scheduling via **`Deno.cron`** in the server process (daily sync), with a + manual "sync now" action in the UI. ### D2. Identity: ATProto OAuth, confidential client, DID allowlist -`@atproto/oauth-client-node` handles DPoP, PAR, `private_key_jwt`, and token refresh. We use it for **identity only** (scope `atproto`); the app never touches a PDS after login. Authorization is `ALLOWED_DIDS` (two DIDs) checked after callback — anyone else gets a 403. App sessions are plain HTTP-only cookies backed by a sessions table; the library's state/session stores are also SQLite tables. *Alternatives*: passkeys/local passwords (less machinery, but the household is standardizing on ATProto identity and this eliminates all credential storage); Tailscale-only access (breaks the OAuth flow — the authorization server must fetch client metadata over the public internet). - -- **`APP_URL` derives everything origin-shaped**: `client_id` = `{APP_URL}/client-metadata.json`, redirect URI = `{APP_URL}/oauth/callback`, JWKS at `{APP_URL}/jwks.json`. Client metadata and JWKS are **dynamic SvelteKit routes**, not static files, so they always reflect live env. Caddy proxies the whole app; no special path handling. -- Changing `APP_URL` changes the OAuth client identity → both users re-consent. Accepted cost, documented in README. -- **Local development** (non-https `APP_URL`): the client falls back to atproto's loopback-client exception — `http://localhost` client_id with metadata in query params, public client (no keyset), redirect to `127.0.0.1` — so `deno task dev` works without the public origin. Production always uses the confidential client above. +`@atproto/oauth-client-node` handles DPoP, PAR, `private_key_jwt`, and token +refresh. We use it for **identity only** (scope `atproto`); the app never +touches a PDS after login. Authorization is `ALLOWED_DIDS` (two DIDs) checked +after callback — anyone else gets a 403. App sessions are plain HTTP-only +cookies backed by a sessions table; the library's state/session stores are also +SQLite tables. _Alternatives_: passkeys/local passwords (less machinery, but the +household is standardizing on ATProto identity and this eliminates all +credential storage); Tailscale-only access (breaks the OAuth flow — the +authorization server must fetch client metadata over the public internet). + +- **`APP_URL` derives everything origin-shaped**: `client_id` = + `{APP_URL}/client-metadata.json`, redirect URI = `{APP_URL}/oauth/callback`, + JWKS at `{APP_URL}/jwks.json`. Client metadata and JWKS are **dynamic + SvelteKit routes**, not static files, so they always reflect live env. Caddy + proxies the whole app; no special path handling. +- Changing `APP_URL` changes the OAuth client identity → both users re-consent. + Accepted cost, documented in README. +- **Local development** (non-https `APP_URL`): the client falls back to + atproto's loopback-client exception — `http://localhost` client_id with + metadata in query params, public client (no keyset), redirect to `127.0.0.1` — + so `deno task dev` works without the public origin. Production always uses the + confidential client above. ### D3. Ingestion: raw archive first, then idempotent normalization -Every sync stores the **verbatim response JSON** in `raw_syncs(id, fetched_at, payload, ok, error)` before anything else. Normalization is a pure, idempotent function from payload → upserts, keyed on SimpleFIN ids: - -- `connections(id, access_url, claimed_at)` — plural by design even though v1 has one row (a second Bridge account later is an insert, not a migration). The Access URL lives here, not in env; bootstrap is a settings-page flow: paste setup token → POST claim → store. A used token's 403 gets a clear message. -- `accounts` — SimpleFIN id, connection FK, org info, currency, plus app-owned fields (state, type, display name, `last_successful_data_at`). -- `transactions` — SimpleFIN id, account FK, posted/transacted timestamps, `amount_cents`, raw description, pending flag, verbatim `extra` JSON column, and a denormalized `category_id` cache (see D4). -- `balance_snapshots(account_id, captured_at, balance_cents, available_balance_cents)` — one row per account per sync. This is the net-worth history that can never be backfilled; capturing it from day one is the reason it ships in v1. - -**Pending→posted reconciliation**: pending rows are matched to newly posted rows by (account, amount, date proximity); on match the pending row is replaced and any categorization is carried forward via a `reconciliation` event (D4). Unmatched stale pending rows are removed. *Alternative*: discard-and-refetch all pending rows each sync — simpler but loses manual categorization applied to pending transactions, which contradicts the provenance guarantees. - -Because normalization is pure over archived payloads, it is testable against fixtures and **replayable**: if we later want a field we didn't normalize, we re-run over `raw_syncs` history. +Every sync stores the **verbatim response JSON** in +`raw_syncs(id, fetched_at, payload, ok, error)` before anything else. +Normalization is a pure, idempotent function from payload → upserts, keyed on +SimpleFIN ids: + +- `connections(id, access_url, claimed_at)` — plural by design even though v1 + has one row (a second Bridge account later is an insert, not a migration). The + Access URL lives here, not in env; bootstrap is a settings-page flow: paste + setup token → POST claim → store. A used token's 403 gets a clear message. +- `accounts` — SimpleFIN id, connection FK, org info, currency, plus app-owned + fields (state, type, display name, `last_successful_data_at`). +- `transactions` — SimpleFIN id, account FK, posted/transacted timestamps, + `amount_cents`, raw description, pending flag, verbatim `extra` JSON column, + and a denormalized `category_id` cache (see D4). +- `balance_snapshots(account_id, captured_at, balance_cents, available_balance_cents)` + — one row per account per sync. This is the net-worth history that can never + be backfilled; capturing it from day one is the reason it ships in v1. + +**Pending→posted reconciliation**: pending rows are matched to newly posted rows +by (account, amount, date proximity); on match the pending row is replaced and +any categorization is carried forward via a `reconciliation` event (D4). +Unmatched stale pending rows are removed. _Alternative_: discard-and-refetch all +pending rows each sync — simpler but loses manual categorization applied to +pending transactions, which contradicts the provenance guarantees. + +Because normalization is pure over archived payloads, it is testable against +fixtures and **replayable**: if we later want a field we didn't normalize, we +re-run over `raw_syncs` history. ### D4. Categorization: append-only event log with denormalized cache -`categorization_events(id, transaction_id, category_id, source, rule_id, actor_did, created_at)` is append-only; `source ∈ {rule, manual, reconciliation}`. The transaction's current category is the latest event, cached on `transactions.category_id` and updated **in the same DB transaction** as the event insert. *Alternative*: bare `category_id` column — cannot answer "what led to this?", and the deferred request-partner-to-categorize feature would need retrofitting; with the event log it becomes one future table whose requests resolve when a `manual` event by the requestee DID appears. - -- **Categories** are flat: `categories(id, name, kind)` with `kind ∈ {income, expense, transfer}`. A built-in, non-deletable **Transfer** category exists from migration 001; transfer-kind categories are excluded from all income/expense totals (kills the credit-card-payment double-count). Grouping is deferred but migration-safe because transactions only ever reference categories — a future `category_groups` table + nullable `group_id` on categories is purely additive. -- **Rules**: `rules(id, match_type ∈ {exact, contains}, pattern, category_id, created_by_did, created_at, active)`. Matching is case-insensitive against the **raw** description (payee normalization deferred; matching raw keeps provenance truthful). Deterministic precedence, no ordering UI: exact beats contains → longer pattern beats shorter → newer beats older. -- **Invariant: rules never overwrite human decisions.** Rules fire only on uncategorized transactions at sync time. Creating a rule offers retroactive application to existing *uncategorized* transactions only. +`categorization_events(id, transaction_id, category_id, source, rule_id, actor_did, created_at)` +is append-only; `source ∈ {rule, manual, reconciliation}`. The transaction's +current category is the latest event, cached on `transactions.category_id` and +updated **in the same DB transaction** as the event insert. _Alternative_: bare +`category_id` column — cannot answer "what led to this?", and the deferred +request-partner-to-categorize feature would need retrofitting; with the event +log it becomes one future table whose requests resolve when a `manual` event by +the requestee DID appears. + +- **Categories** are flat: `categories(id, name, kind)` with + `kind ∈ {income, expense, transfer}`. A built-in, non-deletable **Transfer** + category exists from migration 001; transfer-kind categories are excluded from + all income/expense totals (kills the credit-card-payment double-count). + Grouping is deferred but migration-safe because transactions only ever + reference categories — a future `category_groups` table + nullable `group_id` + on categories is purely additive. +- **Rules**: + `rules(id, match_type ∈ {exact, contains}, pattern, category_id, created_by_did, created_at, active)`. + Matching is case-insensitive against the **raw** description (payee + normalization deferred; matching raw keeps provenance truthful). Deterministic + precedence, no ordering UI: exact beats contains → longer pattern beats + shorter → newer beats older. +- **Invariant: rules never overwrite human decisions.** Rules fire only on + uncategorized transactions at sync time. Creating a rule offers retroactive + application to existing _uncategorized_ transactions only. ### D5. Account lifecycle: discovery, not creation -Accounts are never added in-app; they appear in the sync feed (state `NEW`), the user classifies them once (type — SimpleFIN provides none — and optional display name) to reach `ACTIVE`, and accounts that vanish from the feed become `INACTIVE` with all history kept. `HIDDEN` excludes an account from reporting without deleting anything. Connection-level errors from sync responses and per-account staleness (`last_successful_data_at`) surface as dashboard banners linking out to SimpleFIN Bridge — the app can point at broken connections but never fix them. +Accounts are never added in-app; they appear in the sync feed (state `NEW`), the +user classifies them once (type — SimpleFIN provides none — and optional display +name) to reach `ACTIVE`, and accounts that vanish from the feed become +`INACTIVE` with all history kept. `HIDDEN` excludes an account from reporting +without deleting anything. Connection-level errors from sync responses and +per-account staleness (`last_successful_data_at`) surface as dashboard banners +linking out to SimpleFIN Bridge — the app can point at broken connections but +never fix them. ### D6. Transport-agnostic core for future native clients -Native desktop (Deno desktop) and mobile frontends are planned later; a future frontend must be able to point at a standalone Quantum backend rather than shipping embedded. We do **not** build a public API in v1 — we prevent fusion: - -- **Service layer**: all domain operations (ledger queries, categorization, rules, reports, sync trigger, account classification) live in transport-agnostic server modules with typed inputs/outputs. SvelteKit load functions and form actions are thin adapters over these services — no SQL or domain logic in routes. When native clients arrive, `/api/*` JSON routes become a second thin adapter over the same services. -- **Opaque session tokens**: application sessions are rows keyed by an opaque token; the web frontend delivers it via HTTP-only cookie. Accepting the same token as an `Authorization: Bearer` credential later is additive. *Alternative*: JWT sessions — needless for two users and harder to revoke. -- **Backend stays the sole OAuth client**: future native apps will not register their own ATProto client metadata. The intended pattern is system-browser login through the backend's existing web OAuth flow, with the resulting session token handed to the native app via deep-link/loopback redirect. One client identity, one DID allowlist, one identity authority. -- Deferred until a native client exists: `/api/*` routes, CORS policy, bearer-token parsing, deep-link handoff. +Native desktop (Deno desktop) and mobile frontends are planned later; a future +frontend must be able to point at a standalone Quantum backend rather than +shipping embedded. We do **not** build a public API in v1 — we prevent fusion: + +- **Service layer**: all domain operations (ledger queries, categorization, + rules, reports, sync trigger, account classification) live in + transport-agnostic server modules with typed inputs/outputs. SvelteKit load + functions and form actions are thin adapters over these services — no SQL or + domain logic in routes. When native clients arrive, `/api/*` JSON routes + become a second thin adapter over the same services. +- **Opaque session tokens**: application sessions are rows keyed by an opaque + token; the web frontend delivers it via HTTP-only cookie. Accepting the same + token as an `Authorization: Bearer` credential later is additive. + _Alternative_: JWT sessions — needless for two users and harder to revoke. +- **Backend stays the sole OAuth client**: future native apps will not register + their own ATProto client metadata. The intended pattern is system-browser + login through the backend's existing web OAuth flow, with the resulting + session token handed to the native app via deep-link/loopback redirect. One + client identity, one DID allowlist, one identity authority. +- Deferred until a native client exists: `/api/*` routes, CORS policy, + bearer-token parsing, deep-link handoff. ### D7. Reporting: SQL over normalized tables -Monthly income vs. expense per category = GROUP BY over posted, non-transfer, non-hidden transactions. Net worth over time = sum of each account's latest snapshot per day, split by balance sign (assets vs. liabilities). No cube/warehouse layer; SQLite over household-scale data is instant. +Monthly income vs. expense per category = GROUP BY over posted, non-transfer, +non-hidden transactions. Net worth over time = sum of each account's latest +snapshot per day, split by balance sign (assets vs. liabilities). No +cube/warehouse layer; SQLite over household-scale data is instant. ## Risks / Trade-offs -- [Access URL is a permanent bank-data credential in the DB] → the SQLite file is secret-grade: document backup + file-permission expectations; app runs as a dedicated user; nothing financial ever goes in logs or URLs. Encryption-at-rest is deliberately out of scope for v1 (the threat model is a home server the owner controls). -- [Pending→posted matching is heuristic; amounts/dates can shift] → conservative matcher (same account, exact amount, small date window); unmatched pending rows are dropped rather than guessed; reconciliation events make every carry-forward auditable. -- [SimpleFIN Bridge outage or bank connection rot] → raw archive means no data loss for periods the Bridge did serve; staleness surfacing makes rot visible within a day; sync failures are recorded on `raw_syncs` rows. -- [`@atproto/oauth-client-node` under Deno npm-compat is less traveled than Node] → validate in the first implementation task (walking skeleton includes a full OAuth round-trip); fallback is running the SvelteKit adapter under Node in a container, which changes nothing else in the design. -- [Bank backfill at bootstrap is shallow (~90 days typical)] → accepted; documented so expectations are set. Balance history starts at day one regardless. -- [Single-process app: cron, web, and DB in one] → fine at household scale; WAL mode + the same-transaction cache invariant keep concurrent request/sync writes safe. +- [Access URL is a permanent bank-data credential in the DB] → the SQLite file + is secret-grade: document backup + file-permission expectations; app runs as a + dedicated user; nothing financial ever goes in logs or URLs. + Encryption-at-rest is deliberately out of scope for v1 (the threat model is a + home server the owner controls). +- [Pending→posted matching is heuristic; amounts/dates can shift] → conservative + matcher (same account, exact amount, small date window); unmatched pending + rows are dropped rather than guessed; reconciliation events make every + carry-forward auditable. +- [SimpleFIN Bridge outage or bank connection rot] → raw archive means no data + loss for periods the Bridge did serve; staleness surfacing makes rot visible + within a day; sync failures are recorded on `raw_syncs` rows. +- [`@atproto/oauth-client-node` under Deno npm-compat is less traveled than + Node] → validate in the first implementation task (walking skeleton includes a + full OAuth round-trip); fallback is running the SvelteKit adapter under Node + in a container, which changes nothing else in the design. +- [Bank backfill at bootstrap is shallow (~90 days typical)] → accepted; + documented so expectations are set. Balance history starts at day one + regardless. +- [Single-process app: cron, web, and DB in one] → fine at household scale; WAL + mode + the same-transaction cache invariant keep concurrent request/sync + writes safe. ## Migration Plan -Greenfield — no data migration. Deployment order: (1) DNS + Caddy route for `APP_URL`, (2) generate OAuth signing key, set env (`APP_URL`, `ALLOWED_DIDS`, `DB_PATH`, key), (3) start app (migrations auto-apply), (4) both users log in, (5) paste SimpleFIN setup token, (6) first sync backfills and snapshots begin. Rollback = stop the process; the SQLite file is the only state. +Greenfield — no data migration. Deployment order: (1) DNS + Caddy route for +`APP_URL`, (2) generate OAuth signing key, set env (`APP_URL`, `ALLOWED_DIDS`, +`DB_PATH`, key), (3) start app (migrations auto-apply), (4) both users log in, +(5) paste SimpleFIN setup token, (6) first sync backfills and snapshots begin. +Rollback = stop the process; the SQLite file is the only state. ## Open Questions -None blocking. Deliberately deferred to later changes: category groups UI/rollups, request-partner-to-categorize, payee normalization, budgets, CSV export. +None blocking. Deliberately deferred to later changes: category groups +UI/rollups, request-partner-to-categorize, payee normalization, budgets, CSV +export. diff --git a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/proposal.md b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/proposal.md index 9a02f42..a212886 100644 --- a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/proposal.md +++ b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/proposal.md @@ -1,26 +1,58 @@ ## Why -We (a two-person household) want a Mint-style personal finance app — categorize transactions and visualize income vs. expense per category plus net worth over time — self-hosted on our own hardware, fed by bank data from the SimpleFIN protocol. No existing tool combines self-hosting, SimpleFIN ingestion, categorization provenance, and AT Protocol identity; this change bootstraps the entire greenfield application. +We (a two-person household) want a Mint-style personal finance app — categorize +transactions and visualize income vs. expense per category plus net worth over +time — self-hosted on our own hardware, fed by bank data from the SimpleFIN +protocol. No existing tool combines self-hosting, SimpleFIN ingestion, +categorization provenance, and AT Protocol identity; this change bootstraps the +entire greenfield application. ## What Changes -- Scaffold a SvelteKit application running on Deno 2.9 with SQLite persistence (`node:sqlite`) and numbered SQL migrations from day one. -- Add AT Protocol OAuth login (via `@atproto/oauth-client-node`) with a two-DID allowlist; both users share a single household ledger. Public HTTPS origin (behind existing Caddy) is configured entirely via `APP_URL`. -- Add SimpleFIN ingestion: one-time setup-token claim flow, daily `Deno.cron` sync, verbatim raw-response archive, idempotent normalization into accounts/transactions, per-sync balance snapshots, and pending→posted reconciliation. -- Add account lifecycle management: accounts are discovered from the sync feed (never created in-app), flow through NEW → ACTIVE → INACTIVE states, require one-time user classification (type + display name), and surface connection errors/staleness with pointers to SimpleFIN Bridge. -- Add categorization: flat categories (income | expense | transfer), exact/contains matching rules, and an append-only categorization event log recording provenance (which rule fired, or which user acted) for every assignment. -- Add reporting: monthly income vs. expense totals per category (transfers excluded) and net worth over time from balance snapshots. +- Scaffold a SvelteKit application running on Deno 2.9 with SQLite persistence + (`node:sqlite`) and numbered SQL migrations from day one. +- Add AT Protocol OAuth login (via `@atproto/oauth-client-node`) with a two-DID + allowlist; both users share a single household ledger. Public HTTPS origin + (behind existing Caddy) is configured entirely via `APP_URL`. +- Add SimpleFIN ingestion: one-time setup-token claim flow, daily `Deno.cron` + sync, verbatim raw-response archive, idempotent normalization into + accounts/transactions, per-sync balance snapshots, and pending→posted + reconciliation. +- Add account lifecycle management: accounts are discovered from the sync feed + (never created in-app), flow through NEW → ACTIVE → INACTIVE states, require + one-time user classification (type + display name), and surface connection + errors/staleness with pointers to SimpleFIN Bridge. +- Add categorization: flat categories (income | expense | transfer), + exact/contains matching rules, and an append-only categorization event log + recording provenance (which rule fired, or which user acted) for every + assignment. +- Add reporting: monthly income vs. expense totals per category (transfers + excluded) and net worth over time from balance snapshots. -Explicitly deferred (but kept cheap by this design): category groups, request-partner-to-categorize, payee normalization, and native desktop/mobile clients (e.g., Deno desktop) — the backend/frontend boundary is kept separable from day one so a future native frontend can point at a Quantum backend instead of shipping with it embedded. +Explicitly deferred (but kept cheap by this design): category groups, +request-partner-to-categorize, payee normalization, and native desktop/mobile +clients (e.g., Deno desktop) — the backend/frontend boundary is kept separable +from day one so a future native frontend can point at a Quantum backend instead +of shipping with it embedded. ## Capabilities ### New Capabilities -- `auth`: AT Protocol OAuth login, DID allowlist authorization, session management, and env-driven client identity (client metadata, JWKS, redirect URI derived from `APP_URL`). -- `simplefin-sync`: SimpleFIN connection bootstrap (setup token → Access URL), scheduled sync, raw response archival, idempotent normalization (accounts, transactions, balance snapshots), and pending→posted reconciliation. -- `account-management`: account discovery from sync data, lifecycle states (NEW/ACTIVE/INACTIVE/HIDDEN), user classification of new accounts, and connection error/staleness surfacing. -- `categorization`: category CRUD, rule-based auto-categorization (exact + contains, deterministic precedence), manual categorization with actor attribution, and the append-only provenance event log. -- `reporting`: monthly income/expense-by-category totals and net-worth-over-time views. + +- `auth`: AT Protocol OAuth login, DID allowlist authorization, session + management, and env-driven client identity (client metadata, JWKS, redirect + URI derived from `APP_URL`). +- `simplefin-sync`: SimpleFIN connection bootstrap (setup token → Access URL), + scheduled sync, raw response archival, idempotent normalization (accounts, + transactions, balance snapshots), and pending→posted reconciliation. +- `account-management`: account discovery from sync data, lifecycle states + (NEW/ACTIVE/INACTIVE/HIDDEN), user classification of new accounts, and + connection error/staleness surfacing. +- `categorization`: category CRUD, rule-based auto-categorization (exact + + contains, deterministic precedence), manual categorization with actor + attribution, and the append-only provenance event log. +- `reporting`: monthly income/expense-by-category totals and net-worth-over-time + views. ### Modified Capabilities @@ -28,8 +60,15 @@ None — greenfield project, no existing specs. ## Impact -- **Code**: entire new SvelteKit/Deno codebase (routes, server-only modules for sync/auth/rules, SQLite schema + migrations). -- **Dependencies**: `@atproto/oauth-client-node` (npm), SvelteKit/Vite toolchain under Deno 2.9; no other external services beyond SimpleFIN Bridge. -- **Systems**: requires a publicly reachable HTTPS origin proxied by existing Caddy (ATProto authorization servers must fetch `client-metadata.json`); SimpleFIN Bridge account with bank connections managed there. -- **Data**: new SQLite database holding financial data and the SimpleFIN Access URL — must be treated as secret-grade and backed up; balance history is unrecoverable if lost. -- **Env surface**: `APP_URL`, `ALLOWED_DIDS`, `DB_PATH`, OAuth client private key. +- **Code**: entire new SvelteKit/Deno codebase (routes, server-only modules for + sync/auth/rules, SQLite schema + migrations). +- **Dependencies**: `@atproto/oauth-client-node` (npm), SvelteKit/Vite toolchain + under Deno 2.9; no other external services beyond SimpleFIN Bridge. +- **Systems**: requires a publicly reachable HTTPS origin proxied by existing + Caddy (ATProto authorization servers must fetch `client-metadata.json`); + SimpleFIN Bridge account with bank connections managed there. +- **Data**: new SQLite database holding financial data and the SimpleFIN Access + URL — must be treated as secret-grade and backed up; balance history is + unrecoverable if lost. +- **Env surface**: `APP_URL`, `ALLOWED_DIDS`, `DB_PATH`, OAuth client private + key. diff --git a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/account-management/spec.md b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/account-management/spec.md index 7e8eed4..0bb2359 100644 --- a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/account-management/spec.md +++ b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/account-management/spec.md @@ -1,45 +1,81 @@ ## ADDED Requirements ### Requirement: Account discovery from sync feed -The system SHALL create account records only from sync data, never via in-app creation. An account id appearing in a sync for the first time SHALL be registered in state `NEW` with its SimpleFIN-provided organization, name, and currency. + +The system SHALL create account records only from sync data, never via in-app +creation. An account id appearing in a sync for the first time SHALL be +registered in state `NEW` with its SimpleFIN-provided organization, name, and +currency. #### Scenario: Unknown account appears in sync + - **WHEN** a sync payload contains an account id not present in the database -- **THEN** an account row is created in state `NEW` and its transactions and balance snapshots are ingested normally +- **THEN** an account row is created in state `NEW` and its transactions and + balance snapshots are ingested normally ### Requirement: Account classification -The system SHALL require one-time user classification of each `NEW` account before it is treated as `ACTIVE`: the user assigns an account type (e.g., checking, savings, credit card, investment) and may set a friendly display name. SimpleFIN provides no account type, so classification is user-supplied. The UI SHALL surface accounts awaiting classification. + +The system SHALL require one-time user classification of each `NEW` account +before it is treated as `ACTIVE`: the user assigns an account type (e.g., +checking, savings, credit card, investment) and may set a friendly display name. +SimpleFIN provides no account type, so classification is user-supplied. The UI +SHALL surface accounts awaiting classification. #### Scenario: User classifies a new account + - **WHEN** a user assigns a type (and optional display name) to a `NEW` account - **THEN** the account transitions to `ACTIVE` and appears in reporting #### Scenario: Unclassified account visibility + - **WHEN** any account is in state `NEW` - **THEN** the dashboard shows a prompt to classify it ### Requirement: Account lifecycle states -The system SHALL track account states `NEW`, `ACTIVE`, `INACTIVE`, and `HIDDEN`. An account that stops appearing in sync feeds SHALL transition to `INACTIVE` automatically; its transactions, categorizations, and snapshots SHALL be retained. A user MAY mark an account `HIDDEN` to exclude it from all reporting without deleting data, and MAY unhide it later. The system SHALL never delete account history. + +The system SHALL track account states `NEW`, `ACTIVE`, `INACTIVE`, and `HIDDEN`. +An account that stops appearing in sync feeds SHALL transition to `INACTIVE` +automatically; its transactions, categorizations, and snapshots SHALL be +retained. A user MAY mark an account `HIDDEN` to exclude it from all reporting +without deleting data, and MAY unhide it later. The system SHALL never delete +account history. #### Scenario: Account vanishes from feed -- **WHEN** an `ACTIVE` account is absent from a successful sync of its connection -- **THEN** the account transitions to `INACTIVE` and all historical data remains queryable + +- **WHEN** an `ACTIVE` account is absent from a successful sync of its + connection +- **THEN** the account transitions to `INACTIVE` and all historical data remains + queryable #### Scenario: Inactive account reappears + - **WHEN** an `INACTIVE` account id appears in a sync again -- **THEN** the account returns to `ACTIVE` (or `NEW` if it was never classified) and ingestion resumes +- **THEN** the account returns to `ACTIVE` (or `NEW` if it was never classified) + and ingestion resumes #### Scenario: User hides an account + - **WHEN** a user marks an account `HIDDEN` -- **THEN** the account and its transactions are excluded from reports until unhidden, and no data is deleted +- **THEN** the account and its transactions are excluded from reports until + unhidden, and no data is deleted ### Requirement: Connection health surfacing -The system SHALL surface connection errors returned in sync responses and per-account staleness. Each account SHALL track `last_successful_data_at`. When a sync reports connection-level errors or an account's data is stale beyond a threshold, the dashboard SHALL display a banner identifying the institution and directing the user to resolve it at SimpleFIN Bridge (the app cannot repair connections). + +The system SHALL surface connection errors returned in sync responses and +per-account staleness. Each account SHALL track `last_successful_data_at`. When +a sync reports connection-level errors or an account's data is stale beyond a +threshold, the dashboard SHALL display a banner identifying the institution and +directing the user to resolve it at SimpleFIN Bridge (the app cannot repair +connections). #### Scenario: Sync response contains a connection error + - **WHEN** a sync response includes an error for an institution -- **THEN** the dashboard shows a banner naming the institution with a link out to SimpleFIN Bridge +- **THEN** the dashboard shows a banner naming the institution with a link out + to SimpleFIN Bridge #### Scenario: Account data goes stale -- **WHEN** an `ACTIVE` account's `last_successful_data_at` exceeds the staleness threshold + +- **WHEN** an `ACTIVE` account's `last_successful_data_at` exceeds the staleness + threshold - **THEN** the dashboard indicates the account has not updated since that time diff --git a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/auth/spec.md b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/auth/spec.md index 16a702c..e86da78 100644 --- a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/auth/spec.md +++ b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/auth/spec.md @@ -1,51 +1,92 @@ ## ADDED Requirements ### Requirement: ATProto OAuth login -The system SHALL authenticate users via AT Protocol OAuth using handle-based login. The user enters their handle (or DID); the system resolves it, performs the OAuth authorization flow (PAR, PKCE, DPoP) against the user's authorization server, and establishes an application session on success. The system SHALL request only the `atproto` scope and SHALL NOT access the user's PDS data after authentication. + +The system SHALL authenticate users via AT Protocol OAuth using handle-based +login. The user enters their handle (or DID); the system resolves it, performs +the OAuth authorization flow (PAR, PKCE, DPoP) against the user's authorization +server, and establishes an application session on success. The system SHALL +request only the `atproto` scope and SHALL NOT access the user's PDS data after +authentication. #### Scenario: Successful login with allowlisted handle + - **WHEN** a user whose DID is in the allowlist completes the OAuth flow -- **THEN** the system creates an application session and sets an HTTP-only, Secure session cookie +- **THEN** the system creates an application session and sets an HTTP-only, + Secure session cookie - **AND** the user is redirected to the dashboard #### Scenario: Login with unknown handle + - **WHEN** a user submits a handle that cannot be resolved to a DID -- **THEN** the system shows an error on the login page without starting the OAuth flow +- **THEN** the system shows an error on the login page without starting the + OAuth flow ### Requirement: DID allowlist authorization -The system SHALL authorize users solely by membership in the `ALLOWED_DIDS` environment variable. A successful OAuth authentication with a DID not in the allowlist SHALL be rejected and SHALL NOT create a session or a user record. + +The system SHALL authorize users solely by membership in the `ALLOWED_DIDS` +environment variable. A successful OAuth authentication with a DID not in the +allowlist SHALL be rejected and SHALL NOT create a session or a user record. #### Scenario: Non-allowlisted DID completes OAuth + - **WHEN** the OAuth callback resolves to a DID not present in `ALLOWED_DIDS` -- **THEN** the system responds with 403 and a message that the account is not authorized +- **THEN** the system responds with 403 and a message that the account is not + authorized - **AND** no session or user record is created #### Scenario: Allowlisted DID first login + - **WHEN** an allowlisted DID logs in for the first time - **THEN** the system creates a user record storing the DID and current handle ### Requirement: Env-derived client identity -The system SHALL derive its OAuth client identity entirely from the `APP_URL` environment variable: the client metadata document SHALL be served at `{APP_URL}/client-metadata.json`, the JWKS at `{APP_URL}/jwks.json`, and the redirect URI SHALL be `{APP_URL}/oauth/callback`. Both documents SHALL be generated dynamically at request time from live configuration, not served as static files. The client SHALL be a confidential client using `private_key_jwt` with DPoP-bound tokens. + +The system SHALL derive its OAuth client identity entirely from the `APP_URL` +environment variable: the client metadata document SHALL be served at +`{APP_URL}/client-metadata.json`, the JWKS at `{APP_URL}/jwks.json`, and the +redirect URI SHALL be `{APP_URL}/oauth/callback`. Both documents SHALL be +generated dynamically at request time from live configuration, not served as +static files. The client SHALL be a confidential client using `private_key_jwt` +with DPoP-bound tokens. #### Scenario: Client metadata reflects APP_URL + - **WHEN** `GET /client-metadata.json` is requested -- **THEN** the response is `application/json` with `client_id` equal to `{APP_URL}/client-metadata.json`, `redirect_uris` containing `{APP_URL}/oauth/callback`, `token_endpoint_auth_method` of `private_key_jwt`, `dpop_bound_access_tokens` true, and scope including `atproto` +- **THEN** the response is `application/json` with `client_id` equal to + `{APP_URL}/client-metadata.json`, `redirect_uris` containing + `{APP_URL}/oauth/callback`, `token_endpoint_auth_method` of `private_key_jwt`, + `dpop_bound_access_tokens` true, and scope including `atproto` #### Scenario: JWKS served for client authentication + - **WHEN** `GET /jwks.json` is requested -- **THEN** the response contains the public JWK(s) corresponding to the configured OAuth signing key +- **THEN** the response contains the public JWK(s) corresponding to the + configured OAuth signing key ### Requirement: Session management -The system SHALL persist application sessions and OAuth client state (state store, session store) in SQLite. Sessions SHALL be identified by opaque tokens, delivered to the web frontend via HTTP-only cookie; the token format SHALL NOT assume cookie transport, so future native clients can present the same token as a bearer credential. Every route except login, the OAuth callback, client metadata, and JWKS SHALL require a valid session. Users SHALL be able to log out, which destroys the application session. + +The system SHALL persist application sessions and OAuth client state (state +store, session store) in SQLite. Sessions SHALL be identified by opaque tokens, +delivered to the web frontend via HTTP-only cookie; the token format SHALL NOT +assume cookie transport, so future native clients can present the same token as +a bearer credential. Every route except login, the OAuth callback, client +metadata, and JWKS SHALL require a valid session. Users SHALL be able to log +out, which destroys the application session. #### Scenario: Unauthenticated access to a protected route + - **WHEN** a request without a valid session cookie targets any protected route - **THEN** the system redirects to the login page #### Scenario: Logout + - **WHEN** an authenticated user triggers logout -- **THEN** the session record is deleted, the cookie is cleared, and subsequent requests are treated as unauthenticated +- **THEN** the session record is deleted, the cookie is cleared, and subsequent + requests are treated as unauthenticated #### Scenario: Session survives server restart -- **WHEN** the server process restarts and a user presents a previously issued valid session cookie + +- **WHEN** the server process restarts and a user presents a previously issued + valid session cookie - **THEN** the session is honored because it is persisted in SQLite diff --git a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/categorization/spec.md b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/categorization/spec.md index 2cd58dc..a20738c 100644 --- a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/categorization/spec.md +++ b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/categorization/spec.md @@ -1,53 +1,98 @@ ## ADDED Requirements ### Requirement: Category management -The system SHALL provide flat categories with a name and a kind of `income`, `expense`, or `transfer`. Users SHALL be able to create, rename, and deactivate categories. A built-in `Transfer` category (kind `transfer`) SHALL exist from initial migration and SHALL NOT be deletable. Transactions SHALL reference categories directly (never any grouping construct), keeping future category grouping purely additive. + +The system SHALL provide flat categories with a name and a kind of `income`, +`expense`, or `transfer`. Users SHALL be able to create, rename, and deactivate +categories. A built-in `Transfer` category (kind `transfer`) SHALL exist from +initial migration and SHALL NOT be deletable. Transactions SHALL reference +categories directly (never any grouping construct), keeping future category +grouping purely additive. #### Scenario: Create a category + - **WHEN** a user creates a category with a name and kind - **THEN** the category is available for rules and manual assignment #### Scenario: Built-in Transfer category is protected + - **WHEN** a user attempts to delete the built-in Transfer category - **THEN** the system refuses ### Requirement: Append-only categorization event log -The system SHALL record every category assignment as an immutable event: transaction id, category id, source (`rule`, `manual`, or `reconciliation`), the rule id for rule events, the acting user's DID for manual events, and a timestamp. A transaction's current category SHALL be the latest event, denormalized onto the transaction row in the same database transaction as the event insert. Events SHALL never be updated or deleted. + +The system SHALL record every category assignment as an immutable event: +transaction id, category id, source (`rule`, `manual`, or `reconciliation`), the +rule id for rule events, the acting user's DID for manual events, and a +timestamp. A transaction's current category SHALL be the latest event, +denormalized onto the transaction row in the same database transaction as the +event insert. Events SHALL never be updated or deleted. #### Scenario: Manual categorization records actor + - **WHEN** an authenticated user assigns a category to a transaction -- **THEN** a `manual` event is appended with that user's DID and the transaction's cached category is updated atomically with it +- **THEN** a `manual` event is appended with that user's DID and the + transaction's cached category is updated atomically with it #### Scenario: Provenance is visible + - **WHEN** a user views a categorized transaction's history -- **THEN** the UI shows every event in order: what assigned it (rule pattern or person), to which category, and when +- **THEN** the UI shows every event in order: what assigned it (rule pattern or + person), to which category, and when #### Scenario: Recategorization preserves history -- **WHEN** a user changes an already-categorized transaction to a different category + +- **WHEN** a user changes an already-categorized transaction to a different + category - **THEN** a new event is appended and prior events remain queryable ### Requirement: Rule-based auto-categorization -The system SHALL support categorization rules with match types `exact` and `contains`, matched case-insensitively against the raw transaction description and, when the provider supplies them, the payee and memo fields (a rule fires if any of these matches). Rules record their creator's DID and creation time. When multiple rules match one transaction, precedence SHALL be deterministic: `exact` beats `contains`, then longer pattern beats shorter, then newer rule beats older. Rule application SHALL append a `rule` event recording the winning rule's id. + +The system SHALL support categorization rules with match types `exact` and +`contains`, matched case-insensitively against the raw transaction description +and, when the provider supplies them, the payee and memo fields (a rule fires if +any of these matches). Rules record their creator's DID and creation time. When +multiple rules match one transaction, precedence SHALL be deterministic: `exact` +beats `contains`, then longer pattern beats shorter, then newer rule beats +older. Rule application SHALL append a `rule` event recording the winning rule's +id. #### Scenario: Rule fires on new transaction at sync -- **WHEN** a sync ingests an uncategorized transaction whose description matches an active rule -- **THEN** the winning rule's category is applied via a `rule` event referencing that rule + +- **WHEN** a sync ingests an uncategorized transaction whose description matches + an active rule +- **THEN** the winning rule's category is applied via a `rule` event referencing + that rule #### Scenario: Precedence between overlapping rules -- **WHEN** a description matches both `contains "AMAZON"` and `contains "AMAZON PRIME"` -- **THEN** the longer pattern's rule wins and the fired rule id is recorded on the event + +- **WHEN** a description matches both `contains "AMAZON"` and + `contains "AMAZON PRIME"` +- **THEN** the longer pattern's rule wins and the fired rule id is recorded on + the event #### Scenario: Rule matches the payee or memo field -- **WHEN** a transaction's description is a terse bank label but its provider-supplied payee or memo matches an active rule + +- **WHEN** a transaction's description is a terse bank label but its + provider-supplied payee or memo matches an active rule - **THEN** the rule fires exactly as if the description had matched ### Requirement: Manual decisions outrank rules -The system SHALL never allow a rule to overwrite a categorization whose latest event is `manual`. Rules fire only on transactions with no current category. When a user creates a rule, the system SHALL offer to retroactively apply it to existing matching transactions that are currently uncategorized, and SHALL NOT touch categorized ones. + +The system SHALL never allow a rule to overwrite a categorization whose latest +event is `manual`. Rules fire only on transactions with no current category. +When a user creates a rule, the system SHALL offer to retroactively apply it to +existing matching transactions that are currently uncategorized, and SHALL NOT +touch categorized ones. #### Scenario: Rule does not overwrite manual choice -- **WHEN** a rule matching a transaction is created or runs, and that transaction's latest event is `manual` + +- **WHEN** a rule matching a transaction is created or runs, and that + transaction's latest event is `manual` - **THEN** the transaction's category is unchanged #### Scenario: Retroactive application on rule creation + - **WHEN** a user creates a rule and accepts the retroactive-apply offer -- **THEN** the rule is applied to all matching currently-uncategorized transactions, each receiving a `rule` event +- **THEN** the rule is applied to all matching currently-uncategorized + transactions, each receiving a `rule` event diff --git a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/reporting/spec.md b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/reporting/spec.md index 8750fc9..db1131a 100644 --- a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/reporting/spec.md +++ b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/reporting/spec.md @@ -1,38 +1,67 @@ ## ADDED Requirements ### Requirement: Monthly income and expense by category -The system SHALL display, for a selected month, total income and total expenses broken down by category, computed from posted transactions of non-hidden accounts. Transactions in transfer-kind categories SHALL be excluded from all totals. Uncategorized transactions SHALL be shown as their own line with a count, so gaps in categorization are visible rather than silently distorting totals. + +The system SHALL display, for a selected month, total income and total expenses +broken down by category, computed from posted transactions of non-hidden +accounts. Transactions in transfer-kind categories SHALL be excluded from all +totals. Uncategorized transactions SHALL be shown as their own line with a +count, so gaps in categorization are visible rather than silently distorting +totals. #### Scenario: Category totals for a month + - **WHEN** a user views the report for a month -- **THEN** each category shows its total for that month (integer-cent arithmetic), grouped into income and expense sections, with an overall income, expense, and net figure +- **THEN** each category shows its total for that month (integer-cent + arithmetic), grouped into income and expense sections, with an overall income, + expense, and net figure #### Scenario: Transfers excluded -- **WHEN** a credit-card payment produced equal-and-opposite transactions categorized as Transfer + +- **WHEN** a credit-card payment produced equal-and-opposite transactions + categorized as Transfer - **THEN** neither side appears in income or expense totals #### Scenario: Uncategorized surfaced + - **WHEN** the selected month contains uncategorized transactions -- **THEN** the report shows an "Uncategorized" line with their total and count, linking to the ledger filtered to them +- **THEN** the report shows an "Uncategorized" line with their total and count, + linking to the ledger filtered to them ### Requirement: Net worth over time -The system SHALL display net worth over time computed from balance snapshots: for each day with data, the sum of every non-hidden account's most recent snapshot on or before that day, with assets (positive balances) and liabilities (negative balances) distinguishable. History SHALL extend back to the earliest snapshot. + +The system SHALL display net worth over time computed from balance snapshots: +for each day with data, the sum of every non-hidden account's most recent +snapshot on or before that day, with assets (positive balances) and liabilities +(negative balances) distinguishable. History SHALL extend back to the earliest +snapshot. #### Scenario: Net worth chart + - **WHEN** a user views the net worth report -- **THEN** a time series shows total net worth per day derived from latest-snapshot-per-account, including asset/liability breakdown +- **THEN** a time series shows total net worth per day derived from + latest-snapshot-per-account, including asset/liability breakdown #### Scenario: Hidden accounts excluded + - **WHEN** an account is marked `HIDDEN` - **THEN** its balances are excluded from the net worth series ### Requirement: Transaction ledger -The system SHALL provide a ledger view of transactions filterable by account, category (including uncategorized), month, and pending status, showing date, account, description, amount, category, and a provenance indicator (rule vs. person). The ledger is the surface for manual categorization. + +The system SHALL provide a ledger view of transactions filterable by account, +category (including uncategorized), month, and pending status, showing date, +account, description, amount, category, and a provenance indicator (rule vs. +person). The ledger is the surface for manual categorization. #### Scenario: Filter to uncategorized + - **WHEN** a user filters the ledger to uncategorized transactions -- **THEN** only transactions with no current category are listed, ready for manual assignment +- **THEN** only transactions with no current category are listed, ready for + manual assignment #### Scenario: Provenance indicator + - **WHEN** a categorized transaction is displayed -- **THEN** the row indicates whether the category came from a rule, a person (with their identity), or reconciliation carry-forward +- **THEN** the row indicates whether the category came from a rule, a person + (with their identity), or reconciliation carry-forward diff --git a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/simplefin-sync/spec.md b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/simplefin-sync/spec.md index d93f507..4d0e07d 100644 --- a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/simplefin-sync/spec.md +++ b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/specs/simplefin-sync/spec.md @@ -1,68 +1,124 @@ ## ADDED Requirements ### Requirement: Connection bootstrap via setup token -The system SHALL provide a settings flow where an authenticated user pastes a one-time SimpleFIN setup token. The system SHALL decode the token, POST to the claim URL, and store the resulting Access URL in the `connections` table. The Access URL SHALL be stored only in the database (never in environment variables or logs). The data model SHALL support multiple connections even though one is expected initially. + +The system SHALL provide a settings flow where an authenticated user pastes a +one-time SimpleFIN setup token. The system SHALL decode the token, POST to the +claim URL, and store the resulting Access URL in the `connections` table. The +Access URL SHALL be stored only in the database (never in environment variables +or logs). The data model SHALL support multiple connections even though one is +expected initially. #### Scenario: Valid setup token claimed + - **WHEN** a user submits a valid, unused setup token -- **THEN** the system claims it, stores a connection row with the Access URL and claim timestamp, and triggers an initial sync +- **THEN** the system claims it, stores a connection row with the Access URL and + claim timestamp, and triggers an initial sync #### Scenario: Already-used setup token + - **WHEN** the claim request returns 403 -- **THEN** the system shows a message that the token was already claimed and a fresh one must be generated at SimpleFIN Bridge +- **THEN** the system shows a message that the token was already claimed and a + fresh one must be generated at SimpleFIN Bridge - **AND** no connection row is created ### Requirement: Scheduled and manual sync -The system SHALL sync each connection once daily via a scheduled job and SHALL provide a manual "sync now" action in the UI. A sync fetches `GET {access_url}/accounts` including pending transactions and a start date that safely overlaps previously fetched data. + +The system SHALL sync each connection once daily via a scheduled job and SHALL +provide a manual "sync now" action in the UI. A sync fetches +`GET {access_url}/accounts` including pending transactions and a start date that +safely overlaps previously fetched data. #### Scenario: Daily scheduled sync + - **WHEN** the daily schedule fires -- **THEN** the system performs a sync for every connection and records the outcome +- **THEN** the system performs a sync for every connection and records the + outcome #### Scenario: Manual sync + - **WHEN** an authenticated user triggers "sync now" - **THEN** a sync runs immediately and the UI reflects the result #### Scenario: Sync failure + - **WHEN** the SimpleFIN request fails (network error or non-2xx) -- **THEN** the system records a failed sync with the error detail and leaves all previously normalized data untouched +- **THEN** the system records a failed sync with the error detail and leaves all + previously normalized data untouched ### Requirement: Raw response archival -The system SHALL store the verbatim response body of every sync attempt in a `raw_syncs` table (fetch timestamp, payload, success flag, error detail) before any normalization occurs. Raw payloads SHALL never be mutated or deleted by the application. + +The system SHALL store the verbatim response body of every sync attempt in a +`raw_syncs` table (fetch timestamp, payload, success flag, error detail) before +any normalization occurs. Raw payloads SHALL never be mutated or deleted by the +application. #### Scenario: Successful sync archived + - **WHEN** a sync response is received -- **THEN** a `raw_syncs` row with the exact response body is committed before normalization begins +- **THEN** a `raw_syncs` row with the exact response body is committed before + normalization begins #### Scenario: Normalization can be replayed + - **WHEN** normalization logic is re-run over an archived payload -- **THEN** it produces the same normalized state as the original run (idempotent, pure function of the payload) +- **THEN** it produces the same normalized state as the original run + (idempotent, pure function of the payload) ### Requirement: Idempotent normalization -The system SHALL normalize archived payloads into `accounts`, `transactions`, and `balance_snapshots` via upserts keyed on SimpleFIN identifiers. Monetary amounts SHALL be converted from SimpleFIN's numeric strings to integer cents exactly once at normalization. Each transaction row SHALL retain the verbatim SimpleFIN `extra` payload in a JSON column. Running normalization twice over the same payload SHALL produce no duplicate rows. + +The system SHALL normalize archived payloads into `accounts`, `transactions`, +and `balance_snapshots` via upserts keyed on SimpleFIN identifiers. Monetary +amounts SHALL be converted from SimpleFIN's numeric strings to integer cents +exactly once at normalization. Each transaction row SHALL retain the verbatim +SimpleFIN `extra` payload in a JSON column. Running normalization twice over the +same payload SHALL produce no duplicate rows. #### Scenario: New transaction ingested + - **WHEN** a payload contains a transaction id not yet in the database -- **THEN** a transaction row is inserted with amount as integer cents, raw description, provider-supplied payee and memo when present, timestamps, pending flag, and verbatim extra JSON +- **THEN** a transaction row is inserted with amount as integer cents, raw + description, provider-supplied payee and memo when present, timestamps, + pending flag, and verbatim extra JSON #### Scenario: Repeated payload is a no-op + - **WHEN** the same payload is normalized a second time - **THEN** row counts and contents are unchanged ### Requirement: Balance snapshots -The system SHALL record one balance snapshot per account per successful sync fetch (a sync may perform an extra deep-backfill fetch when it discovers a new account), capturing balance and available balance (integer cents) with the capture timestamp, to power net-worth-over-time reporting. Snapshots SHALL never be deleted by the application. + +The system SHALL record one balance snapshot per account per successful sync +fetch (a sync may perform an extra deep-backfill fetch when it discovers a new +account), capturing balance and available balance (integer cents) with the +capture timestamp, to power net-worth-over-time reporting. Snapshots SHALL never +be deleted by the application. #### Scenario: Snapshot captured on sync + - **WHEN** a successful sync fetch reports an account balance -- **THEN** a snapshot row is inserted for that account with the reported balance and timestamp +- **THEN** a snapshot row is inserted for that account with the reported balance + and timestamp ### Requirement: Pending-to-posted reconciliation -The system SHALL reconcile pending transactions when they post. A newly posted transaction SHALL be matched to an existing pending row by same account, identical amount, and date proximity within a small window. On match, the pending row is replaced by the posted transaction and any existing categorization is carried forward via a categorization event with source `reconciliation`. Pending rows absent from the feed and unmatched by any posted transaction SHALL be removed. + +The system SHALL reconcile pending transactions when they post. A newly posted +transaction SHALL be matched to an existing pending row by same account, +identical amount, and date proximity within a small window. On match, the +pending row is replaced by the posted transaction and any existing +categorization is carried forward via a categorization event with source +`reconciliation`. Pending rows absent from the feed and unmatched by any posted +transaction SHALL be removed. #### Scenario: Categorized pending transaction posts under a new id + - **WHEN** a posted transaction matches a pending row that has a category -- **THEN** the posted transaction replaces the pending row, receives the same category, and a `reconciliation` categorization event records the carry-forward +- **THEN** the posted transaction replaces the pending row, receives the same + category, and a `reconciliation` categorization event records the + carry-forward #### Scenario: Stale pending transaction disappears -- **WHEN** a pending row no longer appears in the feed and no posted transaction matches it + +- **WHEN** a pending row no longer appears in the feed and no posted transaction + matches it - **THEN** the pending row is removed diff --git a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/tasks.md b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/tasks.md index 1c6ccac..f11949a 100644 --- a/openspec/changes/archive/2026-07-14-bootstrap-finance-app/tasks.md +++ b/openspec/changes/archive/2026-07-14-bootstrap-finance-app/tasks.md @@ -1,49 +1,95 @@ ## 1. Foundation -- [x] 1.1 Scaffold SvelteKit project running under Deno 2.9 (deno.json tasks for dev/build/start, adapter choice per design D1) with a health-check route -- [x] 1.2 Implement SQLite bootstrap via `node:sqlite`: open `DB_PATH`, enable WAL, numbered-migration runner with `schema_version` table applied at startup -- [x] 1.3 Write migration 001: users, sessions, oauth state/session stores, connections, accounts, transactions, balance_snapshots, raw_syncs, categories (seed built-in Transfer), rules, categorization_events -- [x] 1.4 Add config module reading and validating `APP_URL`, `ALLOWED_DIDS`, `DB_PATH`, and OAuth signing key; fail fast with clear errors on missing config -- [x] 1.5 Establish the service-layer convention (design D6): domain operations as transport-agnostic modules under `src/lib/server/`, SvelteKit loads/actions as thin adapters only — no SQL or domain logic in routes +- [x] 1.1 Scaffold SvelteKit project running under Deno 2.9 (deno.json tasks for + dev/build/start, adapter choice per design D1) with a health-check route +- [x] 1.2 Implement SQLite bootstrap via `node:sqlite`: open `DB_PATH`, enable + WAL, numbered-migration runner with `schema_version` table applied at + startup +- [x] 1.3 Write migration 001: users, sessions, oauth state/session stores, + connections, accounts, transactions, balance_snapshots, raw_syncs, + categories (seed built-in Transfer), rules, categorization_events +- [x] 1.4 Add config module reading and validating `APP_URL`, `ALLOWED_DIDS`, + `DB_PATH`, and OAuth signing key; fail fast with clear errors on missing + config +- [x] 1.5 Establish the service-layer convention (design D6): domain operations + as transport-agnostic modules under `src/lib/server/`, SvelteKit + loads/actions as thin adapters only — no SQL or domain logic in routes ## 2. Authentication (specs/auth) -- [x] 2.1 Dynamic routes for `/client-metadata.json` and `/jwks.json` generated from `APP_URL` and the signing key -- [x] 2.2 Integrate `@atproto/oauth-client-node` with SQLite-backed state/session stores; login page with handle input; `/oauth/callback` handler (validates the library works under Deno npm-compat — fallback per design risk if not) -- [x] 2.3 DID allowlist check on callback: create/update user record for allowlisted DIDs, 403 otherwise -- [x] 2.4 Application sessions: HTTP-only Secure cookie, SQLite sessions table, hooks guard on all protected routes, logout -- [x] 2.5 Verify full OAuth round-trip end-to-end through the public `APP_URL` origin (both household DIDs) +- [x] 2.1 Dynamic routes for `/client-metadata.json` and `/jwks.json` generated + from `APP_URL` and the signing key +- [x] 2.2 Integrate `@atproto/oauth-client-node` with SQLite-backed + state/session stores; login page with handle input; `/oauth/callback` + handler (validates the library works under Deno npm-compat — fallback per + design risk if not) +- [x] 2.3 DID allowlist check on callback: create/update user record for + allowlisted DIDs, 403 otherwise +- [x] 2.4 Application sessions: HTTP-only Secure cookie, SQLite sessions table, + hooks guard on all protected routes, logout +- [x] 2.5 Verify full OAuth round-trip end-to-end through the public `APP_URL` + origin (both household DIDs) ## 3. SimpleFIN ingestion (specs/simplefin-sync) -- [x] 3.1 Settings page: setup-token paste → decode → claim → store connection; handle already-claimed 403 with clear message; trigger initial sync -- [x] 3.2 Sync engine: fetch `/accounts` (pending included, overlapping start-date), archive verbatim payload to raw_syncs (success and failure rows) before any processing -- [x] 3.3 Idempotent normalization: upsert accounts and transactions (integer cents, verbatim extra JSON) keyed on SimpleFIN ids; pure function over payload with fixture-based tests including double-run idempotency +- [x] 3.1 Settings page: setup-token paste → decode → claim → store connection; + handle already-claimed 403 with clear message; trigger initial sync +- [x] 3.2 Sync engine: fetch `/accounts` (pending included, overlapping + start-date), archive verbatim payload to raw_syncs (success and failure + rows) before any processing +- [x] 3.3 Idempotent normalization: upsert accounts and transactions (integer + cents, verbatim extra JSON) keyed on SimpleFIN ids; pure function over + payload with fixture-based tests including double-run idempotency - [x] 3.4 Balance snapshots: insert one row per account per successful sync -- [x] 3.5 Pending→posted reconciliation: conservative matcher (account + exact amount + date window), carry categorization forward via `reconciliation` event, remove stale pending rows; tests for the categorized-pending-reposts case -- [x] 3.6 Schedule daily sync with `Deno.cron` and add manual "sync now" action; record outcomes and update `last_successful_data_at` per account +- [x] 3.5 Pending→posted reconciliation: conservative matcher (account + exact + amount + date window), carry categorization forward via `reconciliation` + event, remove stale pending rows; tests for the + categorized-pending-reposts case +- [x] 3.6 Schedule daily sync with `Deno.cron` and add manual "sync now" action; + record outcomes and update `last_successful_data_at` per account ## 4. Account lifecycle (specs/account-management) -- [x] 4.1 Discovery: register unknown account ids from sync as `NEW`; auto-transition vanished accounts to `INACTIVE` and reappearing ones back -- [x] 4.2 Classification UI: dashboard prompt for `NEW` accounts, assign type + optional display name → `ACTIVE`; hide/unhide action -- [x] 4.3 Connection health: parse sync-response errors, dashboard banners naming the institution with SimpleFIN Bridge link-out, staleness indicator from `last_successful_data_at` +- [x] 4.1 Discovery: register unknown account ids from sync as `NEW`; + auto-transition vanished accounts to `INACTIVE` and reappearing ones back +- [x] 4.2 Classification UI: dashboard prompt for `NEW` accounts, assign type + + optional display name → `ACTIVE`; hide/unhide action +- [x] 4.3 Connection health: parse sync-response errors, dashboard banners + naming the institution with SimpleFIN Bridge link-out, staleness indicator + from `last_successful_data_at` ## 5. Categorization (specs/categorization) -- [x] 5.1 Category management UI/API: create, rename, deactivate; enforce non-deletable built-in Transfer -- [x] 5.2 Event log core: append event + update denormalized `transactions.category_id` in one DB transaction; manual events record actor DID -- [x] 5.3 Rules engine: exact/contains case-insensitive matching on raw description, deterministic precedence (exact > contains, longer > shorter, newer > older), fire only on uncategorized transactions during sync; unit tests for precedence and the manual-outranks-rule invariant -- [x] 5.4 Rules UI: create/deactivate rules, retroactive-apply offer scoped to currently-uncategorized matches with result count -- [x] 5.5 Provenance UI: per-transaction history view showing every event (rule pattern / person / reconciliation, category, timestamp) +- [x] 5.1 Category management UI/API: create, rename, deactivate; enforce + non-deletable built-in Transfer +- [x] 5.2 Event log core: append event + update denormalized + `transactions.category_id` in one DB transaction; manual events record + actor DID +- [x] 5.3 Rules engine: exact/contains case-insensitive matching on raw + description, deterministic precedence (exact > contains, longer > shorter, + newer > older), fire only on uncategorized transactions during sync; unit + tests for precedence and the manual-outranks-rule invariant +- [x] 5.4 Rules UI: create/deactivate rules, retroactive-apply offer scoped to + currently-uncategorized matches with result count +- [x] 5.5 Provenance UI: per-transaction history view showing every event (rule + pattern / person / reconciliation, category, timestamp) ## 6. Reporting (specs/reporting) -- [x] 6.1 Transaction ledger: filters (account, category incl. uncategorized, month, pending), inline manual categorization, provenance indicator per row -- [x] 6.2 Monthly report: income and expense totals per category (posted, non-hidden, transfer-excluded), net figure, Uncategorized line linking to filtered ledger -- [x] 6.3 Net worth over time: latest-snapshot-per-account-per-day series with asset/liability split, excluding hidden accounts +- [x] 6.1 Transaction ledger: filters (account, category incl. uncategorized, + month, pending), inline manual categorization, provenance indicator per + row +- [x] 6.2 Monthly report: income and expense totals per category (posted, + non-hidden, transfer-excluded), net figure, Uncategorized line linking to + filtered ledger +- [x] 6.3 Net worth over time: latest-snapshot-per-account-per-day series with + asset/liability split, excluding hidden accounts ## 7. Deployment & verification -- [x] 7.1 Production build + run task; README covering Caddy route, env setup, key generation, backup expectations (SQLite file is secret-grade), and known limits (~90-day backfill, APP_URL change forces re-consent) -- [x] 7.2 End-to-end walkthrough on real infrastructure: both users log in, claim real setup token, first sync lands, classify accounts, create rules, categorize manually, verify monthly report and net worth chart +- [x] 7.1 Production build + run task; README covering Caddy route, env setup, + key generation, backup expectations (SQLite file is secret-grade), and + known limits (~90-day backfill, APP_URL change forces re-consent) +- [x] 7.2 End-to-end walkthrough on real infrastructure: both users log in, + claim real setup token, first sync lands, classify accounts, create rules, + categorize manually, verify monthly report and net worth chart diff --git a/openspec/changes/archive/2026-07-14-dashboard-overview/design.md b/openspec/changes/archive/2026-07-14-dashboard-overview/design.md index 0a46e40..ff0163a 100644 --- a/openspec/changes/archive/2026-07-14-dashboard-overview/design.md +++ b/openspec/changes/archive/2026-07-14-dashboard-overview/design.md @@ -2,28 +2,57 @@ ## Context -The dashboard (`src/routes/(app)/+page.svelte`) shows advisory banners, account balances, and last-sync time. Everything the new overview needs already exists in services: `reports.ts` computes monthly income/expense by category, `ledger.ts` lists transactions with filters and a limit, and `SpendingPie.svelte` renders the category pie on the Reports page. Amounts are integer cents; pending transactions have `posted = NULL` and an effective timestamp fallback (`posted → transacted_at → created_at`) already defined in `ledger.ts`. +The dashboard (`src/routes/(app)/+page.svelte`) shows advisory banners, account +balances, and last-sync time. Everything the new overview needs already exists +in services: `reports.ts` computes monthly income/expense by category, +`ledger.ts` lists transactions with filters and a limit, and +`SpendingPie.svelte` renders the category pie on the Reports page. Amounts are +integer cents; pending transactions have `posted = NULL` and an effective +timestamp fallback (`posted → transacted_at → created_at`) already defined in +`ledger.ts`. ## Goals / Non-Goals **Goals:** -- Month-to-date glance on the dashboard: spend pie, income/expense totals, pending stats, recent transactions. + +- Month-to-date glance on the dashboard: spend pie, income/expense totals, + pending stats, recent transactions. - Maximum reuse of existing aggregation and components; no schema changes. **Non-Goals:** + - Month selection on the dashboard (Reports already does that). - Dashboard configurability/widgets. - Any change to report-page behavior. ## Decisions -- **D1 — Reuse the monthly report aggregation for the current month.** The dashboard loader calls the same reports service used by the Reports page with the current `YYYY-MM`. This inherits the established semantics for free: posted-only, non-hidden accounts, transfer-kind excluded, uncategorized surfaced as its own line. No second aggregation path to keep consistent. -- **D2 — Pending stats are current-month, cross-account.** Count and sum of `pending = 1`, non-removed transactions of non-hidden accounts whose effective timestamp falls in the current month. Small addition to the reports (or ledger) service; uses the existing `monthRange` + effective-timestamp expression. -- **D3 — Recent transactions via `listLedger` with a small limit.** One call, `limit: 8` (design constant, not user-configurable), no filters — the existing sort (pending first, then effective date desc) is exactly the "what just happened" ordering wanted here. Each row links to the ledger. This also means any future display-name overlay in the ledger service is inherited automatically. -- **D4 — Pie shows expenses only; income and expenses get stat tiles.** Mixing income into a spend pie misreads; the pie reuses `SpendingPie` fed with the expense side of the month aggregation, uncategorized included as a slice. +- **D1 — Reuse the monthly report aggregation for the current month.** The + dashboard loader calls the same reports service used by the Reports page with + the current `YYYY-MM`. This inherits the established semantics for free: + posted-only, non-hidden accounts, transfer-kind excluded, uncategorized + surfaced as its own line. No second aggregation path to keep consistent. +- **D2 — Pending stats are current-month, cross-account.** Count and sum of + `pending = 1`, non-removed transactions of non-hidden accounts whose effective + timestamp falls in the current month. Small addition to the reports (or + ledger) service; uses the existing `monthRange` + effective-timestamp + expression. +- **D3 — Recent transactions via `listLedger` with a small limit.** One call, + `limit: 8` (design constant, not user-configurable), no filters — the existing + sort (pending first, then effective date desc) is exactly the "what just + happened" ordering wanted here. Each row links to the ledger. This also means + any future display-name overlay in the ledger service is inherited + automatically. +- **D4 — Pie shows expenses only; income and expenses get stat tiles.** Mixing + income into a spend pie misreads; the pie reuses `SpendingPie` fed with the + expense side of the month aggregation, uncategorized included as a slice. ## Risks / Trade-offs -- [Sparse early-month data makes the pie trivial] → Acceptable; sections render empty states ("No spending yet this month") rather than hiding, so the layout is stable. -- [Dashboard loader gains 2–3 queries] → All are indexed single-month scans on a personal-scale SQLite database; negligible. -- [`SpendingPie` may need light parameterization (size/legend) for dashboard density] → Prefer props over a forked component. +- [Sparse early-month data makes the pie trivial] → Acceptable; sections render + empty states ("No spending yet this month") rather than hiding, so the layout + is stable. +- [Dashboard loader gains 2–3 queries] → All are indexed single-month scans on a + personal-scale SQLite database; negligible. +- [`SpendingPie` may need light parameterization (size/legend) for dashboard + density] → Prefer props over a forked component. diff --git a/openspec/changes/archive/2026-07-14-dashboard-overview/proposal.md b/openspec/changes/archive/2026-07-14-dashboard-overview/proposal.md index 6605509..b2c6981 100644 --- a/openspec/changes/archive/2026-07-14-dashboard-overview/proposal.md +++ b/openspec/changes/archive/2026-07-14-dashboard-overview/proposal.md @@ -2,13 +2,21 @@ ## Why -The dashboard currently shows account balances, sync status, and advisory banners — it answers "what do I have?" but not "what's happening this month?". The month-to-date picture (spend by category, money in/out, pending activity, latest transactions) already exists in the data model and reporting services but requires visiting Reports and Ledger separately. +The dashboard currently shows account balances, sync status, and advisory +banners — it answers "what do I have?" but not "what's happening this month?". +The month-to-date picture (spend by category, money in/out, pending activity, +latest transactions) already exists in the data model and reporting services but +requires visiting Reports and Ledger separately. ## What Changes -- Add a current-month spending pie chart to the dashboard, reusing the existing `SpendingPie` component and monthly report aggregation. -- Add month-to-date stat tiles: total income, total expenses, and pending transaction count + amount (all scoped to the current month, posted-vs-pending per existing reporting semantics). -- Add a "recent transactions" list showing a handful of the latest transactions with links into the ledger. +- Add a current-month spending pie chart to the dashboard, reusing the existing + `SpendingPie` component and monthly report aggregation. +- Add month-to-date stat tiles: total income, total expenses, and pending + transaction count + amount (all scoped to the current month, posted-vs-pending + per existing reporting semantics). +- Add a "recent transactions" list showing a handful of the latest transactions + with links into the ledger. - No schema changes; no new services beyond a pending-stats aggregate. ## Capabilities @@ -19,12 +27,18 @@ None. ### Modified Capabilities -- `reporting`: New requirement for a dashboard month-to-date overview (spend breakdown, income/expense totals, pending stats, recent transactions). Existing report-page requirements are unchanged. +- `reporting`: New requirement for a dashboard month-to-date overview (spend + breakdown, income/expense totals, pending stats, recent transactions). + Existing report-page requirements are unchanged. ## Impact -- `src/routes/(app)/+page.server.ts` / `+page.svelte` — dashboard loader and layout gain the new sections. -- `src/lib/server/services/reports.ts` — reuse of monthly aggregation; small addition for pending count/amount. -- `src/lib/server/services/ledger.ts` — reuse of `listLedger` with a small limit for recent transactions. -- `src/lib/components/SpendingPie.svelte` — reused as-is (or lightly parameterized). +- `src/routes/(app)/+page.server.ts` / `+page.svelte` — dashboard loader and + layout gain the new sections. +- `src/lib/server/services/reports.ts` — reuse of monthly aggregation; small + addition for pending count/amount. +- `src/lib/server/services/ledger.ts` — reuse of `listLedger` with a small limit + for recent transactions. +- `src/lib/components/SpendingPie.svelte` — reused as-is (or lightly + parameterized). - No migrations, no API changes, no new dependencies. diff --git a/openspec/changes/archive/2026-07-14-dashboard-overview/specs/reporting/spec.md b/openspec/changes/archive/2026-07-14-dashboard-overview/specs/reporting/spec.md index 3f766cd..22b9f51 100644 --- a/openspec/changes/archive/2026-07-14-dashboard-overview/specs/reporting/spec.md +++ b/openspec/changes/archive/2026-07-14-dashboard-overview/specs/reporting/spec.md @@ -3,20 +3,39 @@ ## ADDED Requirements ### Requirement: Dashboard month-to-date overview -The system SHALL display on the dashboard, scoped to the current calendar month and to non-hidden accounts: (1) a spending pie chart of expense totals by category computed from posted transactions with the same semantics as the monthly report (transfer-kind categories excluded, uncategorized shown as its own slice); (2) total income and total expenses from posted transactions; (3) the count and total amount of pending transactions whose effective date falls in the current month; and (4) a small fixed number of the most recent transactions, each linking to the transaction ledger. Each section SHALL render a clear empty state when the month has no qualifying data. + +The system SHALL display on the dashboard, scoped to the current calendar month +and to non-hidden accounts: (1) a spending pie chart of expense totals by +category computed from posted transactions with the same semantics as the +monthly report (transfer-kind categories excluded, uncategorized shown as its +own slice); (2) total income and total expenses from posted transactions; (3) +the count and total amount of pending transactions whose effective date falls in +the current month; and (4) a small fixed number of the most recent transactions, +each linking to the transaction ledger. Each section SHALL render a clear empty +state when the month has no qualifying data. #### Scenario: Current month at a glance + - **WHEN** a user views the dashboard during a month with posted transactions -- **THEN** the spend pie reflects that month's expense totals by category, and stat tiles show the month's total income and total expenses in integer-cent arithmetic +- **THEN** the spend pie reflects that month's expense totals by category, and + stat tiles show the month's total income and total expenses in integer-cent + arithmetic #### Scenario: Pending stats + - **WHEN** the current month contains pending transactions -- **THEN** the dashboard shows their count and summed amount, distinct from posted income/expense totals +- **THEN** the dashboard shows their count and summed amount, distinct from + posted income/expense totals #### Scenario: Recent transactions + - **WHEN** a user views the dashboard -- **THEN** the most recent transactions (pending first, then by effective date descending) are listed with date, account, displayed name, amount, and a link to the full ledger +- **THEN** the most recent transactions (pending first, then by effective date + descending) are listed with date, account, displayed name, amount, and a link + to the full ledger #### Scenario: Empty month + - **WHEN** the current month has no transactions -- **THEN** the overview sections show empty states rather than being hidden or erroring, and account balances remain visible +- **THEN** the overview sections show empty states rather than being hidden or + erroring, and account balances remain visible diff --git a/openspec/changes/archive/2026-07-14-dashboard-overview/tasks.md b/openspec/changes/archive/2026-07-14-dashboard-overview/tasks.md index a66f746..3fb4baa 100644 --- a/openspec/changes/archive/2026-07-14-dashboard-overview/tasks.md +++ b/openspec/changes/archive/2026-07-14-dashboard-overview/tasks.md @@ -1,22 +1,37 @@ -# Tasks — dashboard-overview - -## 1. Services - -- [x] 1.1 Add a pending-stats aggregate (count + summed cents for pending, non-removed transactions of non-hidden accounts within a month) to the reports service, reusing `monthRange` and the effective-timestamp expression; unit tests alongside existing reports tests -- [x] 1.2 Confirm the monthly report aggregation and `listLedger` cover the dashboard's needs (current-month expense-by-category incl. uncategorized; recent-8 listing) — extend only if a gap emerges, with tests - -## 2. Dashboard loader - -- [x] 2.1 Extend `src/routes/(app)/+page.server.ts` to load current-month report totals, pending stats, and recent transactions (`listLedger` with `limit: 8`) alongside the existing data - -## 3. Dashboard UI - -- [x] 3.1 Add stat tiles (month income, month expenses, pending count + amount) with `tnum` formatting and empty-state handling -- [x] 3.2 Add the current-month spending pie reusing `SpendingPie.svelte` (parameterize size/legend via props if the dashboard needs a denser variant); expense categories plus an uncategorized slice; empty state when no spending -- [x] 3.3 Add the recent-transactions list (date, account, displayed name, amount; pending indicated) linking to `/ledger` -- [x] 3.4 Integrate the new sections into the existing dashboard layout without disturbing banners, balances, or last-sync display - -## 4. Verification - -- [x] 4.1 Service tests pass (`deno` test suite) including new pending-stats tests -- [x] 4.2 Verify in the browser: seeded current-month data renders pie/tiles/recents correctly; an empty month renders stable empty states +# Tasks — dashboard-overview + +## 1. Services + +- [x] 1.1 Add a pending-stats aggregate (count + summed cents for pending, + non-removed transactions of non-hidden accounts within a month) to the + reports service, reusing `monthRange` and the effective-timestamp + expression; unit tests alongside existing reports tests +- [x] 1.2 Confirm the monthly report aggregation and `listLedger` cover the + dashboard's needs (current-month expense-by-category incl. uncategorized; + recent-8 listing) — extend only if a gap emerges, with tests + +## 2. Dashboard loader + +- [x] 2.1 Extend `src/routes/(app)/+page.server.ts` to load current-month report + totals, pending stats, and recent transactions (`listLedger` with + `limit: 8`) alongside the existing data + +## 3. Dashboard UI + +- [x] 3.1 Add stat tiles (month income, month expenses, pending count + amount) + with `tnum` formatting and empty-state handling +- [x] 3.2 Add the current-month spending pie reusing `SpendingPie.svelte` + (parameterize size/legend via props if the dashboard needs a denser + variant); expense categories plus an uncategorized slice; empty state when + no spending +- [x] 3.3 Add the recent-transactions list (date, account, displayed name, + amount; pending indicated) linking to `/ledger` +- [x] 3.4 Integrate the new sections into the existing dashboard layout without + disturbing banners, balances, or last-sync display + +## 4. Verification + +- [x] 4.1 Service tests pass (`deno` test suite) including new pending-stats + tests +- [x] 4.2 Verify in the browser: seeded current-month data renders + pie/tiles/recents correctly; an empty month renders stable empty states diff --git a/openspec/changes/archive/2026-07-14-profile-avatars/design.md b/openspec/changes/archive/2026-07-14-profile-avatars/design.md index 8bf86ad..5df6716 100644 --- a/openspec/changes/archive/2026-07-14-profile-avatars/design.md +++ b/openspec/changes/archive/2026-07-14-profile-avatars/design.md @@ -2,30 +2,58 @@ ## Context -Users are stored as `{did, handle}`; the handle is re-upserted on every login, so it never goes stale for active users. Handles appear in two places: the nav "who" chip (`(app)/+layout.svelte`) and categorization provenance (`actorHandle` in ledger history). The auth spec deliberately forbids the app from touching the user's PDS after login — the OAuth session exists solely to authenticate. Every avatar the app could ever need belongs to an allowlisted app user; there is no arbitrary-profile display. +Users are stored as `{did, handle}`; the handle is re-upserted on every login, +so it never goes stale for active users. Handles appear in two places: the nav +"who" chip (`(app)/+layout.svelte`) and categorization provenance (`actorHandle` +in ledger history). The auth spec deliberately forbids the app from touching the +user's PDS after login — the OAuth session exists solely to authenticate. Every +avatar the app could ever need belongs to an allowlisted app user; there is no +arbitrary-profile display. ## Goals / Non-Goals **Goals:** -- Avatars in the nav chip and categorization provenance, with the handle/DID still discoverable. + +- Avatars in the nav chip and categorization provenance, with the handle/DID + still discoverable. - Zero expansion of the application server's data access; zero schema changes. **Non-Goals:** + - Server-side avatar caching or blob storage. - Profile data sync (display names, bios). - Avatars for non-user identities. ## Decisions -- **D1 — Resolve avatars in the browser via a public redirect service (atp.pics), keyed by handle.** `` returns a 302 to a cached, transformed image. Alternative considered: server-side `getProfile` against the public AppView at login, caching the CDN blob URL on the user row. Rejected because it adds resolution/refresh code and a stored URL that breaks when the user changes their avatar (CID-based), whereas handle-keyed resolution stays fresh via the existing login upsert. The browser fetching a public image also keeps the auth spec's story cleanest: the application server fetches nothing. atp.pics is operated by this project's own user, so the third-party-dependency concern is minimal; the spec stays mechanism-agnostic so the URL scheme can be swapped in one component. -- **D2 — One shared `Avatar` component with text fallback.** Renders the image; on load error or missing handle it falls back to the current textual presentation (handle text in provenance, handle chip in nav). Provenance must never become an empty circle. -- **D3 — Provenance popover works for hover, focus, and touch.** The avatar in categorization history is a focusable element; hover or keyboard focus reveals the handle, and the DID is exposed via accessible label/title (matching today's `title={did}` behavior on the nav chip). +- **D1 — Resolve avatars in the browser via a public redirect service + (atp.pics), keyed by handle.** `` + returns a 302 to a cached, transformed image. Alternative considered: + server-side `getProfile` against the public AppView at login, caching the CDN + blob URL on the user row. Rejected because it adds resolution/refresh code and + a stored URL that breaks when the user changes their avatar (CID-based), + whereas handle-keyed resolution stays fresh via the existing login upsert. The + browser fetching a public image also keeps the auth spec's story cleanest: the + application server fetches nothing. atp.pics is operated by this project's own + user, so the third-party-dependency concern is minimal; the spec stays + mechanism-agnostic so the URL scheme can be swapped in one component. +- **D2 — One shared `Avatar` component with text fallback.** Renders the image; + on load error or missing handle it falls back to the current textual + presentation (handle text in provenance, handle chip in nav). Provenance must + never become an empty circle. +- **D3 — Provenance popover works for hover, focus, and touch.** The avatar in + categorization history is a focusable element; hover or keyboard focus reveals + the handle, and the DID is exposed via accessible label/title (matching + today's `title={did}` behavior on the nav chip). ## Risks / Trade-offs -- [atp.pics outage → broken avatars] → D2 fallback restores today's text UI; no functionality is lost. -- [Browser requests reveal handle lookups to the avatar service] → Public data, low sensitivity, and the operator is the app's own user; acceptable. -- [Popovers on touch devices are awkward] → Tap toggles the popover (focus-based), and the DID/handle remain in accessible attributes regardless. +- [atp.pics outage → broken avatars] → D2 fallback restores today's text UI; no + functionality is lost. +- [Browser requests reveal handle lookups to the avatar service] → Public data, + low sensitivity, and the operator is the app's own user; acceptable. +- [Popovers on touch devices are awkward] → Tap toggles the popover + (focus-based), and the DID/handle remain in accessible attributes regardless. ## Migration Plan diff --git a/openspec/changes/archive/2026-07-14-profile-avatars/proposal.md b/openspec/changes/archive/2026-07-14-profile-avatars/proposal.md index 6cff6de..5e30a21 100644 --- a/openspec/changes/archive/2026-07-14-profile-avatars/proposal.md +++ b/openspec/changes/archive/2026-07-14-profile-avatars/proposal.md @@ -2,15 +2,27 @@ ## Why -Users are currently represented by bare handle text (nav chip, categorization provenance). ATProto identities come with public profile pictures; showing them makes provenance glanceable ("who categorized this?") and the app feel personal — without expanding the app's access to user data. +Users are currently represented by bare handle text (nav chip, categorization +provenance). ATProto identities come with public profile pictures; showing them +makes provenance glanceable ("who categorized this?") and the app feel personal +— without expanding the app's access to user data. ## What Changes -- Display the logged-in user's avatar in the nav "who" chip alongside their handle. -- Replace the handle text in categorization history / provenance displays with the actor's avatar; the handle (and DID) appear in a popover on hover/focus, so provenance detail is preserved and keyboard/touch accessible. -- Avatars are resolved from **public** ATProto data by the browser (via an avatar service such as atp.pics, keyed by handle); the application server never fetches profile data and never uses its OAuth session to read the user's PDS. -- Graceful degradation: when an avatar fails to load or a user has none, fall back to the current text presentation. -- Amend the `auth` spec's post-login data-access requirement to explicitly permit public, unauthenticated profile-picture resolution while continuing to forbid authenticated PDS access. +- Display the logged-in user's avatar in the nav "who" chip alongside their + handle. +- Replace the handle text in categorization history / provenance displays with + the actor's avatar; the handle (and DID) appear in a popover on hover/focus, + so provenance detail is preserved and keyboard/touch accessible. +- Avatars are resolved from **public** ATProto data by the browser (via an + avatar service such as atp.pics, keyed by handle); the application server + never fetches profile data and never uses its OAuth session to read the user's + PDS. +- Graceful degradation: when an avatar fails to load or a user has none, fall + back to the current text presentation. +- Amend the `auth` spec's post-login data-access requirement to explicitly + permit public, unauthenticated profile-picture resolution while continuing to + forbid authenticated PDS access. ## Capabilities @@ -20,12 +32,18 @@ None. ### Modified Capabilities -- `auth`: The "no PDS access after authentication" requirement is clarified — authenticated PDS access remains forbidden; rendering avatars from public ATProto data is allowed. A new requirement covers avatar display and fallback behavior. +- `auth`: The "no PDS access after authentication" requirement is clarified — + authenticated PDS access remains forbidden; rendering avatars from public + ATProto data is allowed. A new requirement covers avatar display and fallback + behavior. ## Impact - `src/routes/(app)/+layout.svelte` — nav chip gains an avatar image. -- `src/routes/(app)/ledger/+page.svelte` — provenance actor display becomes avatar + popover. +- `src/routes/(app)/ledger/+page.svelte` — provenance actor display becomes + avatar + popover. - Possibly a small shared `Avatar` component in `src/lib/components/`. -- No schema changes (avatars are keyed by handle, which `users` already stores and refreshes at login); no server-side fetching; no new dependencies. -- External: relies on a public avatar-resolution endpoint (atp.pics) at image-load time; the design records this choice and the fallback contract. +- No schema changes (avatars are keyed by handle, which `users` already stores + and refreshes at login); no server-side fetching; no new dependencies. +- External: relies on a public avatar-resolution endpoint (atp.pics) at + image-load time; the design records this choice and the fallback contract. diff --git a/openspec/changes/archive/2026-07-14-profile-avatars/specs/auth/spec.md b/openspec/changes/archive/2026-07-14-profile-avatars/specs/auth/spec.md index d44eef0..59c1268 100644 --- a/openspec/changes/archive/2026-07-14-profile-avatars/specs/auth/spec.md +++ b/openspec/changes/archive/2026-07-14-profile-avatars/specs/auth/spec.md @@ -3,34 +3,63 @@ ## MODIFIED Requirements ### Requirement: ATProto OAuth login -The system SHALL authenticate users via AT Protocol OAuth using handle-based login. The user enters their handle (or DID); the system resolves it, performs the OAuth authorization flow (PAR, PKCE, DPoP) against the user's authorization server, and establishes an application session on success. The system SHALL request only the `atproto` scope and SHALL NOT make authenticated requests to the user's PDS after authentication. Resolving and rendering profile pictures from public ATProto profile data — without using the OAuth session or any application credential — is permitted. + +The system SHALL authenticate users via AT Protocol OAuth using handle-based +login. The user enters their handle (or DID); the system resolves it, performs +the OAuth authorization flow (PAR, PKCE, DPoP) against the user's authorization +server, and establishes an application session on success. The system SHALL +request only the `atproto` scope and SHALL NOT make authenticated requests to +the user's PDS after authentication. Resolving and rendering profile pictures +from public ATProto profile data — without using the OAuth session or any +application credential — is permitted. #### Scenario: Successful login with allowlisted handle + - **WHEN** a user whose DID is in the allowlist completes the OAuth flow -- **THEN** the system creates an application session and sets an HTTP-only, Secure session cookie +- **THEN** the system creates an application session and sets an HTTP-only, + Secure session cookie - **AND** the user is redirected to the dashboard #### Scenario: Login with unknown handle + - **WHEN** a user submits a handle that cannot be resolved to a DID -- **THEN** the system shows an error on the login page without starting the OAuth flow +- **THEN** the system shows an error on the login page without starting the + OAuth flow ## ADDED Requirements ### Requirement: Profile picture display -The system SHALL display user profile pictures resolved from public ATProto profile data, keyed by the user's current handle and fetched by the browser without application credentials. Avatars SHALL appear in the navigation user chip alongside the handle, and in categorization provenance displays in place of the handle text, where the handle SHALL be revealed in a popover on hover or keyboard focus and the DID SHALL remain available via an accessible label. When an avatar cannot be loaded or does not exist, the UI SHALL fall back to the textual handle presentation. + +The system SHALL display user profile pictures resolved from public ATProto +profile data, keyed by the user's current handle and fetched by the browser +without application credentials. Avatars SHALL appear in the navigation user +chip alongside the handle, and in categorization provenance displays in place of +the handle text, where the handle SHALL be revealed in a popover on hover or +keyboard focus and the DID SHALL remain available via an accessible label. When +an avatar cannot be loaded or does not exist, the UI SHALL fall back to the +textual handle presentation. #### Scenario: Nav chip shows avatar + - **WHEN** an authenticated user views any app page -- **THEN** the navigation user chip shows their avatar together with their handle +- **THEN** the navigation user chip shows their avatar together with their + handle #### Scenario: Provenance avatar with popover -- **WHEN** a user hovers over or keyboard-focuses the actor avatar in a transaction's categorization history -- **THEN** a popover reveals the actor's handle, and the DID is exposed via an accessible label + +- **WHEN** a user hovers over or keyboard-focuses the actor avatar in a + transaction's categorization history +- **THEN** a popover reveals the actor's handle, and the DID is exposed via an + accessible label #### Scenario: Avatar unavailable + - **WHEN** an avatar image fails to load or the actor has no profile picture -- **THEN** the UI renders the actor's handle as text, and no provenance information is lost +- **THEN** the UI renders the actor's handle as text, and no provenance + information is lost #### Scenario: Server fetches no profile data + - **WHEN** pages containing avatars are rendered -- **THEN** all profile-image requests originate from the browser against public endpoints, and the application server performs no profile-data requests +- **THEN** all profile-image requests originate from the browser against public + endpoints, and the application server performs no profile-data requests diff --git a/openspec/changes/archive/2026-07-14-profile-avatars/tasks.md b/openspec/changes/archive/2026-07-14-profile-avatars/tasks.md index ca413bc..6c6add8 100644 --- a/openspec/changes/archive/2026-07-14-profile-avatars/tasks.md +++ b/openspec/changes/archive/2026-07-14-profile-avatars/tasks.md @@ -2,16 +2,28 @@ ## 1. Avatar component -- [x] 1.1 Create `src/lib/components/Avatar.svelte`: renders the avatar image for a handle (resolution URL per design D1, size prop), falling back to the textual handle presentation on load error or missing handle -- [x] 1.2 Add the popover variant for provenance use: focusable trigger, popover with handle on hover/focus (tap-toggle on touch), DID via accessible label/title +- [x] 1.1 Create `src/lib/components/Avatar.svelte`: renders the avatar image + for a handle (resolution URL per design D1, size prop), falling back to + the textual handle presentation on load error or missing handle +- [x] 1.2 Add the popover variant for provenance use: focusable trigger, popover + with handle on hover/focus (tap-toggle on touch), DID via accessible + label/title ## 2. Integration -- [x] 2.1 Nav "who" chip in `src/routes/(app)/+layout.svelte`: avatar beside the handle, keeping the existing `title={did}` -- [x] 2.2 Categorization provenance in `src/routes/(app)/ledger/+page.svelte`: replace actor handle text with the popover avatar (fallback preserves today's text) +- [x] 2.1 Nav "who" chip in `src/routes/(app)/+layout.svelte`: avatar beside the + handle, keeping the existing `title={did}` +- [x] 2.2 Categorization provenance in `src/routes/(app)/ledger/+page.svelte`: + replace actor handle text with the popover avatar (fallback preserves + today's text) ## 3. Verification -- [x] 3.1 Verify in the browser: avatar renders in nav and provenance history; popover works via mouse hover and keyboard focus; DID present in accessible attributes (focus rule verified by construction — sandbox pane lacks window focus, so `:focus` can't be exercised there) -- [x] 3.2 Verify fallback: with the avatar URL unreachable (blocked/bogus handle), text presentation returns and no layout breaks -- [x] 3.3 Confirm no server-side profile fetches were introduced (avatar requests appear only in browser network log) +- [x] 3.1 Verify in the browser: avatar renders in nav and provenance history; + popover works via mouse hover and keyboard focus; DID present in + accessible attributes (focus rule verified by construction — sandbox pane + lacks window focus, so `:focus` can't be exercised there) +- [x] 3.2 Verify fallback: with the avatar URL unreachable (blocked/bogus + handle), text presentation returns and no layout breaks +- [x] 3.3 Confirm no server-side profile fetches were introduced (avatar + requests appear only in browser network log) diff --git a/openspec/changes/archive/2026-07-14-rules-workbench/design.md b/openspec/changes/archive/2026-07-14-rules-workbench/design.md index 2e2a0ee..9af2786 100644 --- a/openspec/changes/archive/2026-07-14-rules-workbench/design.md +++ b/openspec/changes/archive/2026-07-14-rules-workbench/design.md @@ -2,11 +2,24 @@ ## Context -Rules today are `{matchType: exact|contains, pattern, categoryId, createdByDid, active}` (`src/lib/server/services/rules.ts`), matched case-insensitively against description/payee/memo with deterministic precedence (exact > contains > longer > newer). Application flows through the append-only `categorization_events` log with `rule_id` recorded, and the invariant "rules never overwrite a human decision" is enforced structurally (`applyRulesToUncategorized` only touches uncategorized transactions whose latest event is not manual). Management UI is a small form in Settings. `countRuleMatches` probes prospective matches over uncategorized transactions only. The ledger (`ledger.ts`) supports structured filters (account/category/month/pending) but no free text, and its query already joins the categorizing rule for provenance display. +Rules today are +`{matchType: exact|contains, pattern, categoryId, createdByDid, active}` +(`src/lib/server/services/rules.ts`), matched case-insensitively against +description/payee/memo with deterministic precedence (exact > contains > longer + +> newer). Application flows through the append-only `categorization_events` log +> with `rule_id` recorded, and the invariant "rules never overwrite a human +> decision" is enforced structurally (`applyRulesToUncategorized` only touches +> uncategorized transactions whose latest event is not manual). Management UI is +> a small form in Settings. `countRuleMatches` probes prospective matches over +> uncategorized transactions only. The ledger (`ledger.ts`) supports structured +> filters (account/category/month/pending) but no free text, and its query +> already joins the categorizing rule for provenance display. ## Goals / Non-Goals **Goals:** + - Rules as a first-class surface: dedicated page, search, per-rule inspection. - In-context rule creation from any ledger transaction or search. - Amount as an exact-match conjunct; display-name rename as a read-time overlay. @@ -14,33 +27,97 @@ Rules today are `{matchType: exact|contains, pattern, categoryId, createdByDid, - Lay the substrate for future subscription tracking without building it. **Non-Goals:** + - Subscription tracking itself (schedules, expected-charge alerts). -- Amount ranges/tolerances — deliberately rejected for now: Copilot's range UX proved noisy and confusing; exact-with-visibility is the bet. -- Manual per-transaction rename (a future feature; would take precedence over rule renames, purely additively). -- Rule editing beyond today's enable/disable (unchanged scope), reordering, or manual priorities. +- Amount ranges/tolerances — deliberately rejected for now: Copilot's range UX + proved noisy and confusing; exact-with-visibility is the bet. +- Manual per-transaction rename (a future feature; would take precedence over + rule renames, purely additively). +- Rule editing beyond today's enable/disable (unchanged scope), reordering, or + manual priorities. ## Decisions -- **D1 — Amount is a conjunct, not a match type.** `rules.amount_cents` (nullable INTEGER, signed). A rule matches when its text pattern matches (unchanged semantics) AND, if `amount_cents` is set, the transaction's amount equals it exactly. Rationale: the driving case is "payee contains NETFLIX and amount = −1549" — amount narrows a text match. Whether pattern-less amount-only rules are allowed: no — a text pattern remains required, keeping every rule human-readable and avoiding accidental broad matches on common amounts. -- **D2 — Precedence: amount-constrained wins first.** New tiebreak order: amount-constrained beats unconstrained, then exact beats contains, then longer pattern, then newer rule. An amount conjunct is a strictly stronger statement of intent than any text-only refinement, so it outranks exactness. Precedence changes affect only future/retroactive applications; historical events are immutable. -- **D3 — Rename is a read-time overlay sourced from the categorizing rule.** `rules.display_name` (nullable TEXT). The ledger query already joins the rule referenced by the transaction's latest event; the overlay is `COALESCE(rule.display_name, payee-or-description)` in that same join — no per-transaction storage, no rename events, and editing a rule re-renames everywhere instantly. Consequence (accepted): the rename applies to transactions *this rule categorized*; a manually recategorized transaction loses the overlay along with the rule's categorization. That keeps rename provenance identical to category provenance and avoids a second live-matching pass at read time. Raw description/payee stay canonical, stored untouched, and visible in the transaction detail view. -- **D4 — Match health is derived, not stored.** The rule detail view derives from the event log and live matching: last-fired time, fire counts by month, and — the subscription-tracking seed — recent transactions that matched the rule's text pattern but failed its amount conjunct ("pattern hit, amount differs"), which is exactly the price-change signal. No new tables; all queries are per-rule and on demand. -- **D5 — Rule inspection separates "did" from "would".** *Did:* transactions whose events reference the rule (historical fact, from `categorization_events.rule_id`). *Would:* a live probe across all non-removed transactions of non-hidden accounts (widened from today's uncategorized-only `countRuleMatches`), with each hit labeled by its current status: uncategorized / categorized by this rule / categorized by another rule / manual. The probe never mutates anything. -- **D6 — Slide-over tray for creation, one component, two entry points.** From a ledger row: pre-filled with payee (preferred) or description as pattern, `contains` match, the transaction's amount (opt-in checkbox — default off so text-only rules stay the norm), and optional display name. From ledger search: the search term becomes the pattern candidate. On save, the existing retroactive-apply offer semantics run unchanged. The tray posts to the rules route's actions; the ledger page never grows rule logic. -- **D7 — Ledger search is `LIKE` over raw fields plus overlay name.** Case-insensitive substring over description, payee, memo, and the effective display name (via the existing provenance rule join) — "search matches what the ledger displays." Composable with all existing filters as one more `WHERE` conjunct. FTS5 rejected: personal-scale row counts make `LIKE` fine, and rules match with the same substring semantics, keeping search results an honest preview of rule hits. -- **D8 — `/rules` page replaces the Settings section.** Route `src/routes/(app)/rules/` with list + search (over pattern, display name, category) and per-rule detail; nav gains a Rules link; Settings keeps connections and categories. Rule search is server-side for symmetry with ledger search, though the row count would permit client-side. +- **D1 — Amount is a conjunct, not a match type.** `rules.amount_cents` + (nullable INTEGER, signed). A rule matches when its text pattern matches + (unchanged semantics) AND, if `amount_cents` is set, the transaction's amount + equals it exactly. Rationale: the driving case is "payee contains NETFLIX and + amount = −1549" — amount narrows a text match. Whether pattern-less + amount-only rules are allowed: no — a text pattern remains required, keeping + every rule human-readable and avoiding accidental broad matches on common + amounts. +- **D2 — Precedence: amount-constrained wins first.** New tiebreak order: + amount-constrained beats unconstrained, then exact beats contains, then longer + pattern, then newer rule. An amount conjunct is a strictly stronger statement + of intent than any text-only refinement, so it outranks exactness. Precedence + changes affect only future/retroactive applications; historical events are + immutable. +- **D3 — Rename is a read-time overlay sourced from the categorizing rule.** + `rules.display_name` (nullable TEXT). The ledger query already joins the rule + referenced by the transaction's latest event; the overlay is + `COALESCE(rule.display_name, payee-or-description)` in that same join — no + per-transaction storage, no rename events, and editing a rule re-renames + everywhere instantly. Consequence (accepted): the rename applies to + transactions _this rule categorized_; a manually recategorized transaction + loses the overlay along with the rule's categorization. That keeps rename + provenance identical to category provenance and avoids a second live-matching + pass at read time. Raw description/payee stay canonical, stored untouched, and + visible in the transaction detail view. +- **D4 — Match health is derived, not stored.** The rule detail view derives + from the event log and live matching: last-fired time, fire counts by month, + and — the subscription-tracking seed — recent transactions that matched the + rule's text pattern but failed its amount conjunct ("pattern hit, amount + differs"), which is exactly the price-change signal. No new tables; all + queries are per-rule and on demand. +- **D5 — Rule inspection separates "did" from "would".** _Did:_ transactions + whose events reference the rule (historical fact, from + `categorization_events.rule_id`). _Would:_ a live probe across all non-removed + transactions of non-hidden accounts (widened from today's uncategorized-only + `countRuleMatches`), with each hit labeled by its current status: + uncategorized / categorized by this rule / categorized by another rule / + manual. The probe never mutates anything. +- **D6 — Slide-over tray for creation, one component, two entry points.** From a + ledger row: pre-filled with payee (preferred) or description as pattern, + `contains` match, the transaction's amount (opt-in checkbox — default off so + text-only rules stay the norm), and optional display name. From ledger search: + the search term becomes the pattern candidate. On save, the existing + retroactive-apply offer semantics run unchanged. The tray posts to the rules + route's actions; the ledger page never grows rule logic. +- **D7 — Ledger search is `LIKE` over raw fields plus overlay name.** + Case-insensitive substring over description, payee, memo, and the effective + display name (via the existing provenance rule join) — "search matches what + the ledger displays." Composable with all existing filters as one more `WHERE` + conjunct. FTS5 rejected: personal-scale row counts make `LIKE` fine, and rules + match with the same substring semantics, keeping search results an honest + preview of rule hits. +- **D8 — `/rules` page replaces the Settings section.** Route + `src/routes/(app)/rules/` with list + search (over pattern, display name, + category) and per-rule detail; nav gains a Rules link; Settings keeps + connections and categories. Rule search is server-side for symmetry with + ledger search, though the row count would permit client-side. ## Risks / Trade-offs -- [Exact amount silently stops matching when a subscription price changes] → Deliberate: the transaction lands uncategorized (already surfaced by reporting), and D4's "pattern hit, amount differs" view names the cause. Revisit ranges later if this proves noisy in practice. -- [Precedence change reorders winners among existing overlapping rules] → Only two rules exist in practice today and events are immutable; retroactive application remains opt-in. -- [Overlay hides raw descriptions users might search by memory] → D7 searches raw fields *and* overlay, so both vocabularies find the transaction. -- [Widened would-hit probe scans all transactions] → Per-rule, on-demand, indexed personal-scale SQLite; cap the displayed list and show counts. +- [Exact amount silently stops matching when a subscription price changes] → + Deliberate: the transaction lands uncategorized (already surfaced by + reporting), and D4's "pattern hit, amount differs" view names the cause. + Revisit ranges later if this proves noisy in practice. +- [Precedence change reorders winners among existing overlapping rules] → Only + two rules exist in practice today and events are immutable; retroactive + application remains opt-in. +- [Overlay hides raw descriptions users might search by memory] → D7 searches + raw fields _and_ overlay, so both vocabularies find the transaction. +- [Widened would-hit probe scans all transactions] → Per-rule, on-demand, + indexed personal-scale SQLite; cap the displayed list and show counts. ## Migration Plan -One migration: `ALTER TABLE rules ADD COLUMN amount_cents INTEGER; ALTER TABLE rules ADD COLUMN display_name TEXT;` — both nullable, existing rows keep exact behavior. UI moves are code-only. Rollback = revert code; the extra columns are inert. +One migration: +`ALTER TABLE rules ADD COLUMN amount_cents INTEGER; ALTER TABLE rules ADD COLUMN display_name TEXT;` +— both nullable, existing rows keep exact behavior. UI moves are code-only. +Rollback = revert code; the extra columns are inert. ## Open Questions -None blocking. Post-MVP candidates: amount ranges (revisit), rule editing in place, manual rename overlay outranking rule renames. +None blocking. Post-MVP candidates: amount ranges (revisit), rule editing in +place, manual rename overlay outranking rule renames. diff --git a/openspec/changes/archive/2026-07-14-rules-workbench/proposal.md b/openspec/changes/archive/2026-07-14-rules-workbench/proposal.md index ff40e8a..fb696d4 100644 --- a/openspec/changes/archive/2026-07-14-rules-workbench/proposal.md +++ b/openspec/changes/archive/2026-07-14-rules-workbench/proposal.md @@ -2,18 +2,42 @@ ## Why -Categorization rules are currently a small form buried in Settings: creation requires retyping patterns by hand, there is no way to see what a rule has done or would do, and rules can only assign categories. Rules are becoming the primary automation surface of the app — they deserve a dedicated page, an in-context creation flow from the ledger, and richer matching (amount) and effects (display-name rename) that pave the way for subscription tracking. +Categorization rules are currently a small form buried in Settings: creation +requires retyping patterns by hand, there is no way to see what a rule has done +or would do, and rules can only assign categories. Rules are becoming the +primary automation surface of the app — they deserve a dedicated page, an +in-context creation flow from the ledger, and richer matching (amount) and +effects (display-name rename) that pave the way for subscription tracking. ## What Changes -- **Rules page**: move rule management out of Settings into a dedicated `/rules` page with rule search and per-rule inspection. -- **Rule inspection**: a rule detail view shows *what the rule did* (transactions it categorized, from the append-only event log) and *what it would hit* (live match preview across all non-removed transactions, not just uncategorized ones). -- **Create rule from transaction**: a slide-over tray, openable from any ledger transaction (and from ledger search results), pre-filled with the transaction's pattern candidates and amount; rules no longer require a trip to Settings. -- **Amount matching**: a rule may additionally constrain on an exact amount (integer cents, signed). Amount is a conjunct on top of the text pattern, not a new match type. Match-health visibility (e.g., "this rule stopped matching", "amount changed") is surfaced on the rule detail view rather than via tolerant/range matching. -- **Rename effect**: a rule may set a display name for matched transactions. This is a read-time presentation overlay — the raw description/payee remain canonical and stored; no per-transaction rename events are recorded, and editing the rule re-renames everywhere instantly. -- **Precedence**: amount-constrained rules are more specific than otherwise-equal unconstrained rules and win ties. -- **Ledger search**: free-text search over the ledger (description, payee, memo, and effective rule-applied display name), composable with existing account/category/month/pending filters. Search results feed the create-rule slide-over with the search term as the pattern candidate. -- **BREAKING** (UI only): the rules section is removed from Settings; Settings retains connection and category management. +- **Rules page**: move rule management out of Settings into a dedicated `/rules` + page with rule search and per-rule inspection. +- **Rule inspection**: a rule detail view shows _what the rule did_ + (transactions it categorized, from the append-only event log) and _what it + would hit_ (live match preview across all non-removed transactions, not just + uncategorized ones). +- **Create rule from transaction**: a slide-over tray, openable from any ledger + transaction (and from ledger search results), pre-filled with the + transaction's pattern candidates and amount; rules no longer require a trip to + Settings. +- **Amount matching**: a rule may additionally constrain on an exact amount + (integer cents, signed). Amount is a conjunct on top of the text pattern, not + a new match type. Match-health visibility (e.g., "this rule stopped matching", + "amount changed") is surfaced on the rule detail view rather than via + tolerant/range matching. +- **Rename effect**: a rule may set a display name for matched transactions. + This is a read-time presentation overlay — the raw description/payee remain + canonical and stored; no per-transaction rename events are recorded, and + editing the rule re-renames everywhere instantly. +- **Precedence**: amount-constrained rules are more specific than + otherwise-equal unconstrained rules and win ties. +- **Ledger search**: free-text search over the ledger (description, payee, memo, + and effective rule-applied display name), composable with existing + account/category/month/pending filters. Search results feed the create-rule + slide-over with the search term as the pattern candidate. +- **BREAKING** (UI only): the rules section is removed from Settings; Settings + retains connection and category management. ## Capabilities @@ -23,14 +47,24 @@ None — all changes extend existing capabilities. ### Modified Capabilities -- `categorization`: rules gain an optional exact-amount conjunct, an optional display-name effect (read-time overlay), extended precedence, a dedicated management page with search and inspection, and an in-ledger creation flow. -- `reporting`: the transaction ledger gains free-text search (matching displayed names, not just raw fields), and displayed transaction names reflect rule rename overlays. +- `categorization`: rules gain an optional exact-amount conjunct, an optional + display-name effect (read-time overlay), extended precedence, a dedicated + management page with search and inspection, and an in-ledger creation flow. +- `reporting`: the transaction ledger gains free-text search (matching displayed + names, not just raw fields), and displayed transaction names reflect rule + rename overlays. ## Impact -- Schema: `rules` table gains `amount_cents` (nullable) and `display_name` (nullable) columns — one migration; existing rows unaffected. -- `src/lib/server/services/rules.ts` — matching, precedence, prospective-probe widening, match-health queries. -- `src/lib/server/services/ledger.ts` — search filter, display-name overlay in query results. -- New route `src/routes/(app)/rules/` (page + server loader); `settings/+page.svelte` sheds its rules section; `ledger/+page.svelte` gains search input and the slide-over tray (new component). +- Schema: `rules` table gains `amount_cents` (nullable) and `display_name` + (nullable) columns — one migration; existing rows unaffected. +- `src/lib/server/services/rules.ts` — matching, precedence, prospective-probe + widening, match-health queries. +- `src/lib/server/services/ledger.ts` — search filter, display-name overlay in + query results. +- New route `src/routes/(app)/rules/` (page + server loader); + `settings/+page.svelte` sheds its rules section; `ledger/+page.svelte` gains + search input and the slide-over tray (new component). - Nav gains a Rules link (`(app)/+layout.svelte`). -- Future-facing: exact-amount rules plus event-log history are the substrate for later subscription tracking (not in scope here). +- Future-facing: exact-amount rules plus event-log history are the substrate for + later subscription tracking (not in scope here). diff --git a/openspec/changes/archive/2026-07-14-rules-workbench/specs/categorization/spec.md b/openspec/changes/archive/2026-07-14-rules-workbench/specs/categorization/spec.md index 3a7e64b..60f5a29 100644 --- a/openspec/changes/archive/2026-07-14-rules-workbench/specs/categorization/spec.md +++ b/openspec/changes/archive/2026-07-14-rules-workbench/specs/categorization/spec.md @@ -3,82 +3,157 @@ ## MODIFIED Requirements ### Requirement: Rule-based auto-categorization -The system SHALL support categorization rules with match types `exact` and `contains`, matched case-insensitively against the raw transaction description and, when the provider supplies them, the payee and memo fields (a rule fires if any of these matches). A rule MAY additionally specify an exact amount in signed integer cents; such a rule fires only when the text pattern matches AND the transaction's amount equals the rule's amount exactly. A text pattern is always required — amount-only rules SHALL NOT be permitted. Rules record their creator's DID and creation time. When multiple rules match one transaction, precedence SHALL be deterministic: amount-constrained beats unconstrained, then `exact` beats `contains`, then longer pattern beats shorter, then newer rule beats older. Rule application SHALL append a `rule` event recording the winning rule's id. + +The system SHALL support categorization rules with match types `exact` and +`contains`, matched case-insensitively against the raw transaction description +and, when the provider supplies them, the payee and memo fields (a rule fires if +any of these matches). A rule MAY additionally specify an exact amount in signed +integer cents; such a rule fires only when the text pattern matches AND the +transaction's amount equals the rule's amount exactly. A text pattern is always +required — amount-only rules SHALL NOT be permitted. Rules record their +creator's DID and creation time. When multiple rules match one transaction, +precedence SHALL be deterministic: amount-constrained beats unconstrained, then +`exact` beats `contains`, then longer pattern beats shorter, then newer rule +beats older. Rule application SHALL append a `rule` event recording the winning +rule's id. #### Scenario: Rule fires on new transaction at sync -- **WHEN** a sync ingests an uncategorized transaction whose description matches an active rule -- **THEN** the winning rule's category is applied via a `rule` event referencing that rule + +- **WHEN** a sync ingests an uncategorized transaction whose description matches + an active rule +- **THEN** the winning rule's category is applied via a `rule` event referencing + that rule #### Scenario: Precedence between overlapping rules -- **WHEN** a description matches both `contains "AMAZON"` and `contains "AMAZON PRIME"` -- **THEN** the longer pattern's rule wins and the fired rule id is recorded on the event + +- **WHEN** a description matches both `contains "AMAZON"` and + `contains "AMAZON PRIME"` +- **THEN** the longer pattern's rule wins and the fired rule id is recorded on + the event #### Scenario: Rule matches the payee or memo field -- **WHEN** a transaction's description is a terse bank label but its provider-supplied payee or memo matches an active rule + +- **WHEN** a transaction's description is a terse bank label but its + provider-supplied payee or memo matches an active rule - **THEN** the rule fires exactly as if the description had matched #### Scenario: Amount conjunct narrows a match -- **WHEN** a transaction matches a rule's text pattern but its amount differs from the rule's specified amount + +- **WHEN** a transaction matches a rule's text pattern but its amount differs + from the rule's specified amount - **THEN** that rule does not fire for the transaction #### Scenario: Amount-constrained rule outranks unconstrained -- **WHEN** a transaction matches both a `contains` rule with a matching amount constraint and an `exact` rule with no amount constraint + +- **WHEN** a transaction matches both a `contains` rule with a matching amount + constraint and an `exact` rule with no amount constraint - **THEN** the amount-constrained rule wins and its id is recorded on the event ## ADDED Requirements ### Requirement: Rule display-name overlay -A rule MAY specify a display name. Wherever the system displays a transaction whose current category was assigned by that rule, it SHALL show the rule's display name in place of the raw payee/description. The overlay SHALL be applied at read time: the stored transaction fields remain canonical and unmodified, no per-transaction rename events are recorded, and changing or deactivating the rule's display name SHALL be reflected everywhere immediately. The raw description SHALL remain accessible in the transaction's detail view. + +A rule MAY specify a display name. Wherever the system displays a transaction +whose current category was assigned by that rule, it SHALL show the rule's +display name in place of the raw payee/description. The overlay SHALL be applied +at read time: the stored transaction fields remain canonical and unmodified, no +per-transaction rename events are recorded, and changing or deactivating the +rule's display name SHALL be reflected everywhere immediately. The raw +description SHALL remain accessible in the transaction's detail view. #### Scenario: Renamed transaction display -- **WHEN** a rule with display name "Netflix" categorized a transaction described "NETFLIX.COM 866-579-7172" -- **THEN** ledger and dashboard listings show "Netflix", and the transaction's detail view still shows the raw description + +- **WHEN** a rule with display name "Netflix" categorized a transaction + described "NETFLIX.COM 866-579-7172" +- **THEN** ledger and dashboard listings show "Netflix", and the transaction's + detail view still shows the raw description #### Scenario: Rule edit re-renames instantly + - **WHEN** a user changes a rule's display name -- **THEN** every transaction currently categorized by that rule reflects the new name on next render, with no data migration or new events +- **THEN** every transaction currently categorized by that rule reflects the new + name on next render, with no data migration or new events #### Scenario: Manual recategorization sheds the overlay -- **WHEN** a user manually recategorizes a transaction that a renaming rule had categorized -- **THEN** the transaction's displayed name reverts to its raw payee/description, consistent with the rule no longer being its provenance + +- **WHEN** a user manually recategorizes a transaction that a renaming rule had + categorized +- **THEN** the transaction's displayed name reverts to its raw + payee/description, consistent with the rule no longer being its provenance ### Requirement: Rule management page -The system SHALL provide a dedicated rules page, linked from the primary navigation, replacing rule management in Settings. The page SHALL list all rules with their pattern, match type, amount constraint, display name, target category, and active state, and SHALL provide rule search over these fields. Rule creation, enable, and disable SHALL be available from this page with unchanged semantics (including the retroactive-apply offer on creation). + +The system SHALL provide a dedicated rules page, linked from the primary +navigation, replacing rule management in Settings. The page SHALL list all rules +with their pattern, match type, amount constraint, display name, target +category, and active state, and SHALL provide rule search over these fields. +Rule creation, enable, and disable SHALL be available from this page with +unchanged semantics (including the retroactive-apply offer on creation). #### Scenario: Rules relocated + - **WHEN** a user opens Settings -- **THEN** rule management is no longer present, and the rules page is reachable from the primary navigation +- **THEN** rule management is no longer present, and the rules page is reachable + from the primary navigation #### Scenario: Rule search + - **WHEN** a user searches the rules page for "netflix" - **THEN** rules whose pattern, display name, or category name match are listed ### Requirement: Rule inspection -The system SHALL provide a per-rule detail view showing: (1) what the rule did — transactions whose categorization events reference the rule, from the append-only log; (2) what the rule would hit — a live, read-only probe across all non-removed transactions of non-hidden accounts, each hit labeled with its current categorization status (uncategorized, this rule, another rule, or manual); and (3) match health — when the rule last fired, and for amount-constrained rules, recent transactions that matched the text pattern but not the amount. The probe SHALL NOT modify any transaction or event. + +The system SHALL provide a per-rule detail view showing: (1) what the rule did — +transactions whose categorization events reference the rule, from the +append-only log; (2) what the rule would hit — a live, read-only probe across +all non-removed transactions of non-hidden accounts, each hit labeled with its +current categorization status (uncategorized, this rule, another rule, or +manual); and (3) match health — when the rule last fired, and for +amount-constrained rules, recent transactions that matched the text pattern but +not the amount. The probe SHALL NOT modify any transaction or event. #### Scenario: Historical fires + - **WHEN** a user opens a rule's detail view -- **THEN** transactions the rule categorized are listed from the event log, including ones later recategorized +- **THEN** transactions the rule categorized are listed from the event log, + including ones later recategorized #### Scenario: Prospective matches labeled + - **WHEN** a user views a rule's would-hit preview -- **THEN** matching transactions are shown with their current status, and none are modified +- **THEN** matching transactions are shown with their current status, and none + are modified #### Scenario: Amount drift surfaced -- **WHEN** an amount-constrained rule's text pattern matches recent transactions at a different amount -- **THEN** the detail view surfaces those transactions as pattern-hit/amount-miss, indicating a probable price change + +- **WHEN** an amount-constrained rule's text pattern matches recent transactions + at a different amount +- **THEN** the detail view surfaces those transactions as + pattern-hit/amount-miss, indicating a probable price change ### Requirement: Rule creation from the ledger -The system SHALL allow creating a rule from any ledger transaction via a slide-over tray, without leaving the ledger. The tray SHALL be pre-filled from the transaction: its payee (preferred) or description as the pattern, with the transaction's amount available as an opt-in constraint and an optional display name. When opened from an active ledger search, the search term SHALL be offered as the pattern. Saving SHALL create the rule with unchanged creation semantics, including the offer to retroactively apply it to matching uncategorized transactions. + +The system SHALL allow creating a rule from any ledger transaction via a +slide-over tray, without leaving the ledger. The tray SHALL be pre-filled from +the transaction: its payee (preferred) or description as the pattern, with the +transaction's amount available as an opt-in constraint and an optional display +name. When opened from an active ledger search, the search term SHALL be offered +as the pattern. Saving SHALL create the rule with unchanged creation semantics, +including the offer to retroactively apply it to matching uncategorized +transactions. #### Scenario: Create rule from a transaction + - **WHEN** a user opens the rule tray from a ledger row and saves -- **THEN** a rule is created pre-filled from that transaction without navigating away, and the retroactive-apply offer is presented +- **THEN** a rule is created pre-filled from that transaction without navigating + away, and the retroactive-apply offer is presented #### Scenario: Amount opt-in + - **WHEN** a user opens the rule tray from a transaction - **THEN** the amount constraint is offered but not enabled by default #### Scenario: Search term becomes pattern + - **WHEN** a user opens the rule tray while a ledger text search is active - **THEN** the search term is pre-filled as the rule pattern diff --git a/openspec/changes/archive/2026-07-14-rules-workbench/specs/reporting/spec.md b/openspec/changes/archive/2026-07-14-rules-workbench/specs/reporting/spec.md index cc2121b..7f5bed5 100644 --- a/openspec/changes/archive/2026-07-14-rules-workbench/specs/reporting/spec.md +++ b/openspec/changes/archive/2026-07-14-rules-workbench/specs/reporting/spec.md @@ -3,20 +3,37 @@ ## MODIFIED Requirements ### Requirement: Transaction ledger -The system SHALL provide a ledger view of transactions filterable by account, category (including uncategorized), month, and pending status, and searchable by free text. Text search SHALL match case-insensitively against the raw description, payee, and memo fields and against the effective displayed name produced by rule display-name overlays, so search finds what the user sees. Search SHALL compose with all structured filters. The ledger shows date, account, displayed name (rule overlay applied when present), amount, category, and a provenance indicator (rule vs. person). The ledger is the surface for manual categorization and for creating rules from transactions. + +The system SHALL provide a ledger view of transactions filterable by account, +category (including uncategorized), month, and pending status, and searchable by +free text. Text search SHALL match case-insensitively against the raw +description, payee, and memo fields and against the effective displayed name +produced by rule display-name overlays, so search finds what the user sees. +Search SHALL compose with all structured filters. The ledger shows date, +account, displayed name (rule overlay applied when present), amount, category, +and a provenance indicator (rule vs. person). The ledger is the surface for +manual categorization and for creating rules from transactions. #### Scenario: Filter to uncategorized + - **WHEN** a user filters the ledger to uncategorized transactions -- **THEN** only transactions with no current category are listed, ready for manual assignment +- **THEN** only transactions with no current category are listed, ready for + manual assignment #### Scenario: Provenance indicator + - **WHEN** a categorized transaction is displayed -- **THEN** the row indicates whether the category came from a rule, a person (with their identity), or reconciliation carry-forward +- **THEN** the row indicates whether the category came from a rule, a person + (with their identity), or reconciliation carry-forward #### Scenario: Search matches a renamed transaction -- **WHEN** a rule renames "ACH TRANSFER 4417" to display as "Rent" and a user searches the ledger for "rent" + +- **WHEN** a rule renames "ACH TRANSFER 4417" to display as "Rent" and a user + searches the ledger for "rent" - **THEN** the transaction is found, even though no raw field contains "rent" #### Scenario: Search composes with filters + - **WHEN** a user searches for "netflix" with a month filter active -- **THEN** only that month's transactions matching the text (raw fields or displayed name) are listed +- **THEN** only that month's transactions matching the text (raw fields or + displayed name) are listed diff --git a/openspec/changes/archive/2026-07-14-rules-workbench/tasks.md b/openspec/changes/archive/2026-07-14-rules-workbench/tasks.md index 06789a2..1ba39ef 100644 --- a/openspec/changes/archive/2026-07-14-rules-workbench/tasks.md +++ b/openspec/changes/archive/2026-07-14-rules-workbench/tasks.md @@ -1,37 +1,65 @@ -# Tasks — rules-workbench - -## 1. Schema & matching - -- [x] 1.1 Migration: add nullable `amount_cents` (INTEGER) and `display_name` (TEXT) to `rules` -- [x] 1.2 Extend the `Rule` model and `rules.ts` CRUD for the new fields; require a text pattern (reject amount-only rules) -- [x] 1.3 Amount conjunct in `matches()` and precedence extension in `findWinningRule()` (amount-constrained > exact > longer > newer); unit tests for conjunct misses and the new tiebreak - -## 2. Ledger service - -- [x] 2.1 Display-name overlay in `listLedger`: effective displayed name from the categorizing rule's `display_name` via the existing provenance join, raw fields still returned; tests incl. manual-recategorization shedding the overlay -- [x] 2.2 Free-text search filter (`q`): case-insensitive LIKE over description/payee/memo and the overlay display name, composed with existing filters; tests incl. renamed-transaction hit - -## 3. Rule inspection queries - -- [x] 3.1 "Did" query: transactions whose categorization events reference a rule id (including superseded assignments) -- [x] 3.2 "Would" probe: widen prospective matching to all non-removed transactions of non-hidden accounts, labeling each hit's current status (uncategorized / this rule / other rule / manual); read-only, with tests -- [x] 3.3 Match-health queries: last-fired time, per-month fire counts, and pattern-hit/amount-miss listing for amount-constrained rules; tests - -## 4. Rules page - -- [x] 4.1 Route `src/routes/(app)/rules/`: list with search (pattern, display name, category), create/enable/disable actions moved from Settings (retroactive-apply offer unchanged) -- [x] 4.2 Rule detail view: did / would / match-health sections with capped lists and counts -- [x] 4.3 Add Rules to primary nav in `(app)/+layout.svelte`; remove the rules section from `settings/+page.svelte` (and its server actions), leaving connections and categories - -## 5. Ledger UI - -- [x] 5.1 Search input on the ledger page wired to the `q` filter, composing with existing filter controls and preserved in the query string -- [x] 5.2 Displayed-name overlay in ledger rows; raw description visible in the expanded detail view -- [x] 5.3 Slide-over tray component for rule creation: prefill from transaction (payee-preferred pattern, opt-in amount, optional display name) or from the active search term; posts to rules actions; retroactive-apply offer on save; scroll position preserved -- [x] 5.4 "Create rule" affordance on ledger rows (and visible while a search is active) - -## 6. Verification - -- [x] 6.1 Full test suite passes; new tests cover conjunct matching, precedence, overlay, search, probes, and match health -- [x] 6.2 Browser walkthrough: search → create rule from tray → retroactive apply → renamed rows appear → rule detail shows did/would/health → search finds the renamed transaction -- [x] 6.3 Confirm Settings retains connections/categories only and existing rules behave identically post-migration +# Tasks — rules-workbench + +## 1. Schema & matching + +- [x] 1.1 Migration: add nullable `amount_cents` (INTEGER) and `display_name` + (TEXT) to `rules` +- [x] 1.2 Extend the `Rule` model and `rules.ts` CRUD for the new fields; + require a text pattern (reject amount-only rules) +- [x] 1.3 Amount conjunct in `matches()` and precedence extension in + `findWinningRule()` (amount-constrained > exact > longer > newer); unit + tests for conjunct misses and the new tiebreak + +## 2. Ledger service + +- [x] 2.1 Display-name overlay in `listLedger`: effective displayed name from + the categorizing rule's `display_name` via the existing provenance join, + raw fields still returned; tests incl. manual-recategorization shedding + the overlay +- [x] 2.2 Free-text search filter (`q`): case-insensitive LIKE over + description/payee/memo and the overlay display name, composed with + existing filters; tests incl. renamed-transaction hit + +## 3. Rule inspection queries + +- [x] 3.1 "Did" query: transactions whose categorization events reference a rule + id (including superseded assignments) +- [x] 3.2 "Would" probe: widen prospective matching to all non-removed + transactions of non-hidden accounts, labeling each hit's current status + (uncategorized / this rule / other rule / manual); read-only, with tests +- [x] 3.3 Match-health queries: last-fired time, per-month fire counts, and + pattern-hit/amount-miss listing for amount-constrained rules; tests + +## 4. Rules page + +- [x] 4.1 Route `src/routes/(app)/rules/`: list with search (pattern, display + name, category), create/enable/disable actions moved from Settings + (retroactive-apply offer unchanged) +- [x] 4.2 Rule detail view: did / would / match-health sections with capped + lists and counts +- [x] 4.3 Add Rules to primary nav in `(app)/+layout.svelte`; remove the rules + section from `settings/+page.svelte` (and its server actions), leaving + connections and categories + +## 5. Ledger UI + +- [x] 5.1 Search input on the ledger page wired to the `q` filter, composing + with existing filter controls and preserved in the query string +- [x] 5.2 Displayed-name overlay in ledger rows; raw description visible in the + expanded detail view +- [x] 5.3 Slide-over tray component for rule creation: prefill from transaction + (payee-preferred pattern, opt-in amount, optional display name) or from + the active search term; posts to rules actions; retroactive-apply offer on + save; scroll position preserved +- [x] 5.4 "Create rule" affordance on ledger rows (and visible while a search is + active) + +## 6. Verification + +- [x] 6.1 Full test suite passes; new tests cover conjunct matching, precedence, + overlay, search, probes, and match health +- [x] 6.2 Browser walkthrough: search → create rule from tray → retroactive + apply → renamed rows appear → rule detail shows did/would/health → search + finds the renamed transaction +- [x] 6.3 Confirm Settings retains connections/categories only and existing + rules behave identically post-migration diff --git a/openspec/changes/archive/2026-07-16-csv-backfill-import/design.md b/openspec/changes/archive/2026-07-16-csv-backfill-import/design.md index 0ac7cf6..dd9db00 100644 --- a/openspec/changes/archive/2026-07-16-csv-backfill-import/design.md +++ b/openspec/changes/archive/2026-07-16-csv-backfill-import/design.md @@ -1,30 +1,33 @@ ## Context Quantum's ingestion spine is: fetch bytes → archive them verbatim in `raw_syncs` -→ normalize as a pure, replayable function of the archived payload → upsert. That -shape is load-bearing and this change preserves it. +→ normalize as a pure, replayable function of the archived payload → upsert. +That shape is load-bearing and this change preserves it. The relevant current state: -- `transactions` is keyed `UNIQUE (account_id, sfin_id)` with `sfin_id TEXT NOT - NULL`. Identity comes from the provider; nothing in the codebase synthesizes it. +- `transactions` is keyed `UNIQUE (account_id, sfin_id)` with + `sfin_id TEXT NOT + NULL`. Identity comes from the provider; nothing in the + codebase synthesizes it. - `ingestTransactions` (`src/lib/server/services/sync.ts:183`) is an **authoritative** writer. It assumes its input is the complete truth for the - account over a window: it reconciles new posted rows against pending rows within - ±5 days, and it soft-removes every pending row absent from the feed + account over a window: it reconciles new posted rows against pending rows + within ±5 days, and it soft-removes every pending row absent from the feed (`sync.ts:291-301`). - `accounts.connection_id` is `NOT NULL`, and `account-management` requires that accounts originate only from sync. -- `normalizePayload` (`normalize.ts:61`) is pure and emits `NormalizedAccount[]`. +- `normalizePayload` (`normalize.ts:61`) is pure and emits + `NormalizedAccount[]`. - `applyRulesToUncategorized` (`rules.ts:168`) is unscoped and database-wide. - Reports scope on `t.pending = 0 AND t.removed_at IS NULL` (`reports.ts:7`) and order by `COALESCE(posted, transacted_at, created_at)`. The user-facing problem is one account whose SimpleFIN feed reaches back only -about a week. Critically, its synced history still **accumulates**: each daily sync -tops up the last seven days, so over time the account holds synced rows across a -widening stretch. A backfill CSV and the synced data therefore overlap across a -*region*, not at a boundary line. +about a week. Critically, its synced history still **accumulates**: each daily +sync tops up the last seven days, so over time the account holds synced rows +across a widening stretch. A backfill CSV and the synced data therefore overlap +across a _region_, not at a boundary line. ## Goals / Non-Goals @@ -33,7 +36,8 @@ widening stretch. A backfill CSV and the synced data therefore overlap across a - Close a historical gap in one existing account from a bank's CSV export. - Never corrupt synced data. An import must not be able to delete, reconcile, or overwrite anything sync owns. -- Re-importing the same or an overlapping file must be a no-op, not a duplication. +- Re-importing the same or an overlapping file must be a no-op, not a + duplication. - Surface plausible cross-source duplicates for human judgment before commit. - Make a botched import reversible as a unit. - Preserve the archive-then-replay property for CSV payloads. @@ -61,31 +65,34 @@ sweeps, never updates an existing row. the same `NormalizedTransaction` shape — but it would be a live bug. Its stale sweep soft-deletes every pending row in the account not present in the feed; a 2023 backfill contains none of today's pending rows, so importing would silently -remove all of them. Its ±5-day reconciliation could also let an old CSV row hijack -a live pending row. +remove all of them. Its ±5-day reconciliation could also let an old CSV row +hijack a live pending row. The seam is one level below the function: sync and CSV share the table and the -insert, but not the authority. Sync is authoritative for a window; CSV is additive -into a gap. Encoding that distinction as two writers is both safer and less code -than parameterizing one writer with a `mode` flag, which would leave the dangerous -paths one boolean away from a backfill. +insert, but not the authority. Sync is authoritative for a window; CSV is +additive into a gap. Encoding that distinction as two writers is both safer and +less code than parameterizing one writer with a `mode` flag, which would leave +the dangerous paths one boolean away from a backfill. -**Alternative considered:** Add `authoritative: boolean` to `ingestTransactions`. -Rejected — the flag makes the hazardous branches reachable from the CSV path by -mistake, and the shared code would be only the INSERT statement. +**Alternative considered:** Add `authoritative: boolean` to +`ingestTransactions`. Rejected — the flag makes the hazardous branches reachable +from the CSV path by mistake, and the shared code would be only the INSERT +statement. ### 2. Synthetic identity: content hash + occurrence index -**Decision:** `sfin_id = 'csv:' + hash(account_id, date, amount_cents, -description, occurrence_index)`, where `occurrence_index` is the row's ordinal -among identical rows within the same file. +**Decision:** +`sfin_id = 'csv:' + hash(account_id, date, amount_cents, +description, occurrence_index)`, +where `occurrence_index` is the row's ordinal among identical rows within the +same file. **Why:** `sfin_id` is `NOT NULL` under a unique key, so CSV must invent one. A plain content hash would collapse two genuinely distinct identical transactions (two $4.75 coffees on one Tuesday) into a single row. The occurrence index keeps them distinct while staying deterministic. -This yields idempotency with a pleasing property: because the grouping key *is* +This yields idempotency with a pleasing property: because the grouping key _is_ the row's content, rows within a group are interchangeable. If a second export lists the same group in a different order, or contains a superset of it, the set of derived ids is unchanged for the rows that already exist, and only genuinely @@ -93,26 +100,28 @@ new rows insert. Re-import of an identical or overlapping file is therefore a no-op that never even reaches duplicate review. **Alternative considered:** Rename `sfin_id` to `source_id` and add a `source` -discriminator. Honest, but it touches every query in the codebase, and `import_id` -(decision 5) already records true origin. Namespacing the value with a `csv:` -prefix keeps the lie small and greppable. +discriminator. Honest, but it touches every query in the codebase, and +`import_id` (decision 5) already records true origin. Namespacing the value with +a `csv:` prefix keeps the lie small and greppable. ### 3. Archive on upload, decide later **Decision:** The verbatim file is written to a new `imports` row at upload time -with `status = 'draft'`, before any parsing. The column mapping and the duplicate -decisions are stored on that same row. Wizard steps re-parse the archived bytes. +with `status = 'draft'`, before any parsing. The column mapping and the +duplicate decisions are stored on that same row. Wizard steps re-parse the +archived bytes. **Why:** This does double duty. It honors the existing "archive before normalization" spine, and it solves where a multi-step wizard keeps its state -without stuffing a CSV into a session. Replay determinism requires the mapping and -the decisions to be archived alongside the bytes — bytes alone do not determine -the outcome, since a human chose the mapping and adjudicated the duplicates. +without stuffing a CSV into a session. Replay determinism requires the mapping +and the decisions to be archived alongside the bytes — bytes alone do not +determine the outcome, since a human chose the mapping and adjudicated the +duplicates. **Alternative considered:** A separate `raw_imports` (bytes) plus `imports` -(record). Rejected as over-normalization for a two-person app; the payload column -inside `imports` is written once and never mutated, which preserves the property -that matters. +(record). Rejected as over-normalization for a two-person app; the payload +column inside `imports` is written once and never mutated, which preserves the +property that matters. ### 4. Duplicate detection: amount and date, never description @@ -122,26 +131,26 @@ within ±1 day. Descriptions are displayed side by side but never matched on. Flagged rows default to **skip**. **Why:** The two sources render the same merchant differently — SimpleFIN gives -the bank's raw string (`AMZN Mktp US*2K4LM9QR3`), the CSV gives its own rendering -(`Amazon`). Matching on description would catch almost nothing, which is the worst -outcome: a duplicate check that appears to run and silently passes everything. So -description is evidence for the human, not a filter. +the bank's raw string (`AMZN Mktp US*2K4LM9QR3`), the CSV gives its own +rendering (`Amazon`). Matching on description would catch almost nothing, which +is the worst outcome: a duplicate check that appears to run and silently passes +everything. So description is evidence for the human, not a filter. Amount + date alone is deliberately loose and will throw occasional false positives (a second identical coffee). That is what the review step is for; a loose matcher plus a human beats a tight matcher that misses. The ±1 day window rather than same-day: a CSV's single date column is often the -transaction date while SimpleFIN's `posted` can trail it, so strict same-day would -miss the very rows most at risk. +transaction date while SimpleFIN's `posted` can trail it, so strict same-day +would miss the very rows most at risk. **Skip as the default is principled, not arbitrary.** Where both sources claim a -transaction, the synced row is strictly better: it carries a real SimpleFIN id so -it stays idempotent under future syncs, it holds the bank's own description, and -it already carries categorization history. The CSV row is a photocopy of a record -already held. Declining it loses nothing. The human's job in the wizard is to -catch the false positives and flip those few to keep — a much lighter chore than -adjudicating every row from scratch. +transaction, the synced row is strictly better: it carries a real SimpleFIN id +so it stays idempotent under future syncs, it holds the bank's own description, +and it already carries categorization history. The CSV row is a photocopy of a +record already held. Declining it loses nothing. The human's job in the wizard +is to catch the false positives and flip those few to keep — a much lighter +chore than adjudicating every row from scratch. ### 5. `import_id` on transactions: undo and origin in one column @@ -150,46 +159,49 @@ NULL means synced. **Why:** One nullable column serves three needs. Undo becomes "soft-remove where `import_id = ?`". The ledger's source filter becomes `import_id IS NULL` / -`IS NOT NULL`. The origin line in the history panel joins through it. Retrofitting -this later would mean re-deriving hashes to work out which rows came from which -file. +`IS NOT NULL`. The origin line in the history panel joins through it. +Retrofitting this later would mean re-deriving hashes to work out which rows +came from which file. -Undo **soft-removes** (`removed_at = now`) rather than hard-deleting, matching the -existing stale-pending precedent and the house rule that history is never deleted. -Hard deletion would also collide with the append-only categorization event log, -since events reference `transaction_id`. +Undo **soft-removes** (`removed_at = now`) rather than hard-deleting, matching +the existing stale-pending precedent and the house rule that history is never +deleted. Hard deletion would also collide with the append-only categorization +event log, since events reference `transaction_id`. -Undo sets `imports.status = 'undone'`. Re-importing a corrected file after an undo -generally produces different hashes (a fixed mapping parses different values), so -new rows insert cleanly. In the case where the same mapping is re-imported, the -insert path finds the soft-removed row by unique key and revives it -(`removed_at = NULL`, new `import_id`) rather than erroring. +Undo sets `imports.status = 'undone'`. Re-importing a corrected file after an +undo generally produces different hashes (a fixed mapping parses different +values), so new rows insert cleanly. In the case where the same mapping is +re-imported, the insert path finds the soft-removed row by unique key and +revives it (`removed_at = NULL`, new `import_id`) rather than erroring. ### 6. Which date column to write **Decision:** Map the CSV's date to `posted`, and set `pending = 0`. If the file -exposes a distinct transaction date, map it to `transacted_at`; otherwise leave it -NULL. - -**Why:** Reports scope on `pending = 0` (`reports.ts:7`), not on `posted IS NOT -NULL`, so either choice lands in reports. But a row with `pending = 0` and `posted -IS NULL` is a state sync has never produced, and inventing novel states for -downstream queries to encounter is the riskier path. A CSV export is by definition -a statement of settled history, so `pending = 0` with `posted` set matches the -shape sync emits for posted rows, and every existing query behaves identically. +exposes a distinct transaction date, map it to `transacted_at`; otherwise leave +it NULL. + +**Why:** Reports scope on `pending = 0` (`reports.ts:7`), not on +`posted IS NOT +NULL`, so either choice lands in reports. But a row with +`pending = 0` and `posted +IS NULL` is a state sync has never produced, and +inventing novel states for downstream queries to encounter is the riskier path. +A CSV export is by definition a statement of settled history, so `pending = 0` +with `posted` set matches the shape sync emits for posted rows, and every +existing query behaves identically. The residual imprecision — the file's single date column may be the transaction date rather than the post date — is absorbed by the ±1 day duplicate window. **Date order is part of the mapping.** `03/04/2026` is March 4th at one bank and -April 3rd at another, and the file never says which; guessing wrong silently shifts -an entire import by months. The format (`iso` / `mdy` / `dmy`) is therefore a -stored, user-confirmable field of the mapping, archived like every other mapping -choice so replay stays deterministic. It is inferred from the data where the data -settles it — any component above 12 can only be a day — and falls back to -month-first when every row is ambiguous. ISO dates are unambiguous and always -parse as themselves regardless of the declared format. The preview's stated date -range is the human backstop against a wrong inference. +April 3rd at another, and the file never says which; guessing wrong silently +shifts an entire import by months. The format (`iso` / `mdy` / `dmy`) is +therefore a stored, user-confirmable field of the mapping, archived like every +other mapping choice so replay stays deterministic. It is inferred from the data +where the data settles it — any component above 12 can only be a day — and falls +back to month-first when every row is ambiguous. ISO dates are unambiguous and +always parse as themselves regardless of the declared format. The preview's +stated date range is the human backstop against a wrong inference. ### 7. Amount parsing: a CSV pre-pass, not a change to `parseAmountToCents` @@ -198,40 +210,41 @@ symbols) to a signed decimal string, then hand off to the existing `parseAmountToCents`. Support a single signed column, and separate debit/credit columns, as distinct mapping modes. -**Why:** `parseAmountToCents` (`normalize.ts:48`) is carefully float-free and its -regex `^([+-]?)(\d*)(?:\.(\d+))?$` is a deliberate contract for SimpleFIN's +**Why:** `parseAmountToCents` (`normalize.ts:48`) is carefully float-free and +its regex `^([+-]?)(\d*)(?:\.(\d+))?$` is a deliberate contract for SimpleFIN's numeric strings. Loosening it to accept accountant's parentheses would weaken -validation on the sync path to serve the CSV path. A pre-pass keeps each source's -tolerances where they belong. +validation on the sync path to serve the CSV path. A pre-pass keeps each +source's tolerances where they belong. ### 8. Categorization comes free **Decision:** Call `applyRulesToUncategorized(db)` once after commit. -**Why:** It's already unscoped and database-wide (`rules.ts:168`), and it already -skips transactions whose latest event is `manual`. Backfilled history categorizes -itself against existing rules with no new code and no new event source. +**Why:** It's already unscoped and database-wide (`rules.ts:168`), and it +already skips transactions whose latest event is `manual`. Backfilled history +categorizes itself against existing rules with no new code and no new event +source. ### 9. Surfacing: filter and popover, no ledger badge -**Decision:** `LedgerFilters` gains `source?: 'synced' | 'imported'`; `LedgerRow` -gains origin; the existing history panel gains an origin line. No per-row badge. -The import record lives in Settings, not the main nav. +**Decision:** `LedgerFilters` gains `source?: 'synced' | 'imported'`; +`LedgerRow` gains origin; the existing history panel gains an origin line. No +per-row badge. The import record lives in Settings, not the main nav. -**Why:** Within a backfilled range every row is imported and above it none are, so -a per-row badge would be present on 100% of rows where it appears — carrying no -information exactly where it's densest, while competing with the existing -categorization badge, which answers a different question (*who chose this -category*, not *where did this row come from*). +**Why:** Within a backfilled range every row is imported and above it none are, +so a per-row badge would be present on 100% of rows where it appears — carrying +no information exactly where it's densest, while competing with the existing +categorization badge, which answers a different question (_who chose this +category_, not _where did this row come from_). A seam marker at the import boundary was considered and rejected on stronger grounds: there is no boundary. Because the short-history account accumulates synced rows daily, synced and imported rows genuinely interleave across the same dates. A seam would be a drawn line where none exists. -Origin interest is episodic — it matters during and shortly after an import, then -it is just history. A filter answers it on demand; the popover answers it for a -specific row where the question is actually asked. +Origin interest is episodic — it matters during and shortly after an import, +then it is just history. A filter answers it on demand; the popover answers it +for a specific row where the question is actually asked. ## Risks / Trade-offs @@ -242,8 +255,8 @@ specific row where the question is actually asked. - **The overlap region is wide, so duplicate review could have real volume** → Skip-by-default means clicking through without reading yields the safe outcome - (synced data retained, no double-counting). The cost of inattention is a missing - photocopy, not corrupted reporting. + (synced data retained, no double-counting). The cost of inattention is a + missing photocopy, not corrupted reporting. - **A false positive silently drops a real transaction** (two genuinely distinct identical charges on one day, one already synced) → This is the flip side of @@ -253,58 +266,59 @@ specific row where the question is actually asked. decision. - **`sfin_id` now holds values that are not SimpleFIN ids** → Namespaced `csv:` - prefix makes them greppable, and `import_id` is the authoritative origin field. - Accepted as a small, contained dishonesty rather than a codebase-wide rename. + prefix makes them greppable, and `import_id` is the authoritative origin + field. Accepted as a small, contained dishonesty rather than a codebase-wide + rename. -- **Abandoned drafts accumulate** → Low volume by nature; drafts are invisible to - every surface except the import record. Not worth a reaper. +- **Abandoned drafts accumulate** → Low volume by nature; drafts are invisible + to every surface except the import record. Not worth a reaper. - **Two writers to `transactions` could drift apart** → The CSV writer is - insert-only and deliberately shares no code with `ingestTransactions` beyond the - row shape. The risk is a future schema change updating one and not the other; - mitigated by both paths being covered by tests over the same table. + insert-only and deliberately shares no code with `ingestTransactions` beyond + the row shape. The risk is a future schema change updating one and not the + other; mitigated by both paths being covered by tests over the same table. ## Migration Plan One additive migration (`005_csv_import.sql`): - `CREATE TABLE imports` — `id`, `account_id` (FK, NOT NULL), `filename`, - `uploaded_at`, `payload` (verbatim, never mutated), `mapping` (JSON), `decisions` - (JSON), `status` CHECK in (`draft`, `committed`, `undone`), `committed_at`, - `undone_at`. + `uploaded_at`, `payload` (verbatim, never mutated), `mapping` (JSON), + `decisions` (JSON), `status` CHECK in (`draft`, `committed`, `undone`), + `committed_at`, `undone_at`. - `ALTER TABLE transactions ADD COLUMN import_id INTEGER REFERENCES imports (id)` — nullable, NULL meaning synced, so every existing row is correct without a backfill. - Index on `transactions (import_id)` for undo and the source filter. No changes to existing columns and no data rewrite, so the migration is -forward-only and safe to re-run against a populated database. Rollback is dropping -the table and column; imported rows would need removal first, which the undo flow -already performs. +forward-only and safe to re-run against a populated database. Rollback is +dropping the table and column; imported rows would need removal first, which the +undo flow already performs. ## Resolved During Implementation -- **Hard-refuse implausible date ranges?** No. The preview states the parsed range - and shows the first rows as parsed, which makes a wrong date mapping obvious at a - glance, and undo makes a mistake cheap. A hard refusal would need a notion of - "the account's opening date" that does not exist, and would block legitimate - imports at the edges. Stating the range is enough. +- **Hard-refuse implausible date ranges?** No. The preview states the parsed + range and shows the first rows as parsed, which makes a wrong date mapping + obvious at a glance, and undo makes a mistake cheap. A hard refusal would need + a notion of "the account's opening date" that does not exist, and would block + legitimate imports at the edges. Stating the range is enough. - **List individual skipped rows in the import record?** No — counts only. The - skipped rows are, by construction, transactions the ledger already contains; the - interesting artifact is the archived file, which is retained. -- **Date-only values are anchored at noon UTC, not midnight.** Found by running a - real file: `formatDay` renders in local time, so UTC-midnight rows displayed a day - early across the Americas, and a month's first day would have displayed in the - previous month while still reporting under the correct one. Noon holds the - calendar day for UTC-12..+11 — every timezone these two users will see. - (`formatMonth` already anchors to the 15th for the same reason.) Not universal: - UTC+12 and beyond would still shift. Documented in the tests; the honest fix, if - it ever matters, is rendering date-only rows in UTC. + skipped rows are, by construction, transactions the ledger already contains; + the interesting artifact is the archived file, which is retained. +- **Date-only values are anchored at noon UTC, not midnight.** Found by running + a real file: `formatDay` renders in local time, so UTC-midnight rows displayed + a day early across the Americas, and a month's first day would have displayed + in the previous month while still reporting under the correct one. Noon holds + the calendar day for UTC-12..+11 — every timezone these two users will see. + (`formatMonth` already anchors to the 15th for the same reason.) Not + universal: UTC+12 and beyond would still shift. Documented in the tests; the + honest fix, if it ever matters, is rendering date-only rows in UTC. - **The duplicate window counts whole UTC calendar days, not ±86400 seconds.** A consequence of the above: a CSV row sits at noon while a synced row sits at - whatever hour the bank posted it, so a seconds-based window would mean "within 24 - hours" and would miss rows one calendar day apart (noon vs. midnight is 36 hours). - The spec says "within one day" and now the code means it. + whatever hour the bank posted it, so a seconds-based window would mean "within + 24 hours" and would miss rows one calendar day apart (noon vs. midnight is 36 + hours). The spec says "within one day" and now the code means it. ## Open Questions diff --git a/openspec/changes/archive/2026-07-16-csv-backfill-import/proposal.md b/openspec/changes/archive/2026-07-16-csv-backfill-import/proposal.md index 08b6af5..f97f4fb 100644 --- a/openspec/changes/archive/2026-07-16-csv-backfill-import/proposal.md +++ b/openspec/changes/archive/2026-07-16-csv-backfill-import/proposal.md @@ -17,14 +17,14 @@ that file into the account it belongs to, once, to close the gap. - Imports are **additive only**. A CSV never reconciles pending transactions, never marks accounts inactive, never removes rows, and never overwrites a synced transaction. It inserts, or it does nothing. -- Imported transactions get a **synthetic stable id** derived from their content, - so re-importing the same file (or an overlapping range from a second file) is - a no-op rather than a duplication. -- **Duplicate review**: rows matching an existing transaction in the same account - by amount and near-identical date are surfaced for human judgment before - commit, defaulting to skip. Descriptions are shown side by side to inform the - decision but are not used to match, because the two sources render the same - merchant differently. +- Imported transactions get a **synthetic stable id** derived from their + content, so re-importing the same file (or an overlapping range from a second + file) is a no-op rather than a duplication. +- **Duplicate review**: rows matching an existing transaction in the same + account by amount and near-identical date are surfaced for human judgment + before commit, defaulting to skip. Descriptions are shown side by side to + inform the decision but are not used to match, because the two sources render + the same merchant differently. - **Undo**: every imported transaction records which import produced it, so a botched mapping can be reversed as a unit. - Raw uploaded bytes are archived verbatim before any normalization, alongside @@ -32,8 +32,8 @@ that file into the account it belongs to, once, to close the gap. a replayable pure function of an archived payload" property. - The ledger gains a **source filter** (synced / imported), and a transaction's origin appears in its existing history panel. No new per-row ledger chrome: - within a backfilled range every row is imported, so a per-row badge would carry - no information where it appeared most. + within a backfilled range every row is imported, so a per-row badge would + carry no information where it appeared most. - Rules run over imported transactions after commit, so backfilled history categorizes itself against rules that already exist. @@ -43,12 +43,14 @@ from a CSV, and saved per-account mapping profiles. See Impact. ## Capabilities ### New Capabilities + - `csv-import`: Uploading a CSV of historical transactions, archiving it verbatim, mapping its columns, detecting and adjudicating potential duplicates against existing data, additively ingesting the survivors into one existing account, recording the import as a reversible unit, and undoing it. ### Modified Capabilities + - `reporting`: The transaction ledger requirement gains a source filter (synced vs. imported) that composes with the existing filters, and the ledger surfaces a transaction's origin in its detail/history panel. @@ -58,22 +60,23 @@ from a CSV, and saved per-account mapping profiles. See Impact. **Affected specs**: new `csv-import`; modified `reporting` (transaction ledger requirement). -**Deliberately unaffected**: `account-management` requires that "the system SHALL -create account records only from sync data, never via in-app creation." This -change honors that rule rather than repealing it — a CSV must be attributed to an -account that sync already discovered. Consequently no CSV-only account exists, no -balance snapshots are invented, and the net-worth report is untouched. -`simplefin-sync` is likewise unchanged; its reconciliation and stale-sweep -behavior remain the exclusive authority of sync. +**Deliberately unaffected**: `account-management` requires that "the system +SHALL create account records only from sync data, never via in-app creation." +This change honors that rule rather than repealing it — a CSV must be attributed +to an account that sync already discovered. Consequently no CSV-only account +exists, no balance snapshots are invented, and the net-worth report is +untouched. `simplefin-sync` is likewise unchanged; its reconciliation and +stale-sweep behavior remain the exclusive authority of sync. **Affected code**: + - `migrations/` — new migration: a `raw_imports` archive table; a nullable `import_id` FK on `transactions`. -- `src/lib/server/services/sync.ts` — `ingestTransactions` is *not* reused. It - treats its input as authoritative for the account (its stale-pending sweep would - soft-delete every pending row absent from a backfill CSV, and its ±5-day - reconciliation could let an old CSV row hijack a live pending row). CSV needs a - separate, insert-only writer. +- `src/lib/server/services/sync.ts` — `ingestTransactions` is _not_ reused. It + treats its input as authoritative for the account (its stale-pending sweep + would soft-delete every pending row absent from a backfill CSV, and its ±5-day + reconciliation could let an old CSV row hijack a live pending row). CSV needs + a separate, insert-only writer. - `src/lib/server/services/normalize.ts` — `parseAmountToCents` is reusable but its regex rejects the parenthesized-negative and trailing-minus conventions common in bank CSV exports; it needs a CSV-flavored pre-pass rather than a @@ -82,12 +85,12 @@ behavior remain the exclusive authority of sync. `LedgerRow` gains origin. - `src/lib/server/services/rules.ts` — `applyRulesToUncategorized` is called post-commit. It is already unscoped and database-wide, so it needs no change. -- New service for CSV parsing/mapping/duplicate detection; new Settings routes for - the import wizard and the import record. - -**Risk**: The overlap between a CSV's range and existing synced data is a region, -not a boundary — the short-history account accumulates synced rows daily, so both -sources can claim the same stretch of time. Duplicate review is therefore the -central safeguard, not an edge case. Where both sources claim a transaction, the -synced row wins: it carries a real SimpleFIN id, reconciles correctly under future -syncs, and already holds categorization history. +- New service for CSV parsing/mapping/duplicate detection; new Settings routes + for the import wizard and the import record. + +**Risk**: The overlap between a CSV's range and existing synced data is a +region, not a boundary — the short-history account accumulates synced rows +daily, so both sources can claim the same stretch of time. Duplicate review is +therefore the central safeguard, not an edge case. Where both sources claim a +transaction, the synced row wins: it carries a real SimpleFIN id, reconciles +correctly under future syncs, and already holds categorization history. diff --git a/openspec/changes/archive/2026-07-16-csv-backfill-import/specs/csv-import/spec.md b/openspec/changes/archive/2026-07-16-csv-backfill-import/specs/csv-import/spec.md index 4f16bbb..2dfd22d 100644 --- a/openspec/changes/archive/2026-07-16-csv-backfill-import/specs/csv-import/spec.md +++ b/openspec/changes/archive/2026-07-16-csv-backfill-import/specs/csv-import/spec.md @@ -2,17 +2,17 @@ ### Requirement: Import attribution to an existing account -The system SHALL require every CSV import to be attributed to exactly one account -that already exists in the database. The system SHALL NOT create, rename, or -otherwise modify an account as a result of an import, preserving the -`account-management` rule that accounts originate only from sync data. Accounts in -state `HIDDEN` SHALL NOT be offered as import targets. +The system SHALL require every CSV import to be attributed to exactly one +account that already exists in the database. The system SHALL NOT create, +rename, or otherwise modify an account as a result of an import, preserving the +`account-management` rule that accounts originate only from sync data. Accounts +in state `HIDDEN` SHALL NOT be offered as import targets. #### Scenario: User selects a target account - **WHEN** a user begins a CSV import -- **THEN** the system requires them to choose one existing non-hidden account, and - every transaction ingested from that file is attached to that account +- **THEN** the system requires them to choose one existing non-hidden account, + and every transaction ingested from that file is attached to that account #### Scenario: No account selected @@ -23,15 +23,16 @@ state `HIDDEN` SHALL NOT be offered as import targets. The system SHALL store the uploaded file's bytes verbatim in an `imports` record before any parsing, mapping, or normalization occurs. The archived payload SHALL -never be mutated or deleted by the application. The system SHALL store the chosen -column mapping and the duplicate decisions on the same record, so that the -outcome of an import is a deterministic function of the archived record alone. +never be mutated or deleted by the application. The system SHALL store the +chosen column mapping and the duplicate decisions on the same record, so that +the outcome of an import is a deterministic function of the archived record +alone. #### Scenario: File archived on upload - **WHEN** a user uploads a CSV file -- **THEN** an `imports` row is committed containing the exact uploaded bytes with - status `draft`, before any row is parsed +- **THEN** an `imports` row is committed containing the exact uploaded bytes + with status `draft`, before any row is parsed #### Scenario: Import is replayable @@ -42,29 +43,29 @@ outcome of an import is a deterministic function of the archived record alone. #### Scenario: Unparseable file - **WHEN** an uploaded file cannot be parsed as CSV -- **THEN** the archived record is retained, the failure is stated plainly with the - reason, and no transactions are ingested +- **THEN** the archived record is retained, the failure is stated plainly with + the reason, and no transactions are ingested ### Requirement: Column mapping The system SHALL detect a CSV's date, amount, and description columns from its header row where possible, and SHALL require the user to confirm or correct the mapping before commit. The system SHALL support amounts expressed as a single -signed column and as separate debit and credit columns. The system SHALL treat the -interpretation of ambiguous numeric dates (whether `03/04/2026` is March 4th or -April 3rd) as a user-confirmable part of the mapping, inferring it from the data -where the data settles it and defaulting to month-first otherwise. The system -SHALL accept the negative conventions common to bank exports, including +signed column and as separate debit and credit columns. The system SHALL treat +the interpretation of ambiguous numeric dates (whether `03/04/2026` is March 4th +or April 3rd) as a user-confirmable part of the mapping, inferring it from the +data where the data settles it and defaulting to month-first otherwise. The +system SHALL accept the negative conventions common to bank exports, including parenthesized values and trailing minus signs, and SHALL convert amounts to -integer cents without floating-point arithmetic. The system SHALL map the file's date to the -transaction's posted timestamp and record imported transactions as not pending; -when the file exposes a distinct transaction date, the system SHALL map it to the -transaction date. +integer cents without floating-point arithmetic. The system SHALL map the file's +date to the transaction's posted timestamp and record imported transactions as +not pending; when the file exposes a distinct transaction date, the system SHALL +map it to the transaction date. #### Scenario: Headers auto-detected -- **WHEN** a CSV's header row contains recognizable date, amount, and description - columns +- **WHEN** a CSV's header row contains recognizable date, amount, and + description columns - **THEN** the system pre-selects them and presents the mapping for confirmation #### Scenario: User corrects a wrong guess @@ -92,8 +93,8 @@ transaction date. #### Scenario: Separate debit and credit columns - **WHEN** a file expresses amounts as separate debit and credit columns -- **THEN** the user can map both, and each row resolves to a single signed integer - cent amount +- **THEN** the user can map both, and each row resolves to a single signed + integer cent amount #### Scenario: Imported rows appear in reports @@ -104,8 +105,8 @@ transaction date. ### Requirement: Stable synthetic identity The system SHALL derive a stable, deterministic identifier for each imported -transaction from its content — the target account, date, amount, and description — -combined with an occurrence index distinguishing rows that are otherwise +transaction from its content — the target account, date, amount, and description +— combined with an occurrence index distinguishing rows that are otherwise identical within the same file. Identifiers SHALL be namespaced so they are distinguishable from provider-supplied identifiers. Importing a file whose rows have already been ingested under the same identifiers SHALL NOT create duplicate @@ -119,10 +120,10 @@ rows. #### Scenario: Overlapping files -- **WHEN** a user imports a January–March file and then a February–April file into - the same account -- **THEN** the February–March rows are recognized as already ingested and only the - April rows are added +- **WHEN** a user imports a January–March file and then a February–April file + into the same account +- **THEN** the February–March rows are recognized as already ingested and only + the April rows are added #### Scenario: Genuinely identical transactions @@ -156,8 +157,8 @@ SHALL be able to override any flagged row to be imported. #### Scenario: User keeps a false positive -- **WHEN** a flagged row is in fact a distinct transaction and the user marks it to - be imported +- **WHEN** a flagged row is in fact a distinct transaction and the user marks it + to be imported - **THEN** it is ingested as a new transaction alongside the existing one #### Scenario: Preview before commit @@ -169,20 +170,21 @@ SHALL be able to override any flagged row to be imported. ### Requirement: Additive-only ingestion -An import SHALL only insert transactions. The system SHALL NOT, as a result of an -import, modify or remove any existing transaction, reconcile pending transactions, -change any account's state, or write balance snapshots. Reconciliation and -feed-authority behavior SHALL remain exclusive to sync. +An import SHALL only insert transactions. The system SHALL NOT, as a result of +an import, modify or remove any existing transaction, reconcile pending +transactions, change any account's state, or write balance snapshots. +Reconciliation and feed-authority behavior SHALL remain exclusive to sync. #### Scenario: Pending transactions untouched -- **WHEN** an import is committed into an account that holds pending transactions - absent from the CSV +- **WHEN** an import is committed into an account that holds pending + transactions absent from the CSV - **THEN** those pending transactions remain unchanged and are not removed #### Scenario: Existing transaction not overwritten -- **WHEN** a candidate row resolves to an identifier already present in the account +- **WHEN** a candidate row resolves to an identifier already present in the + account - **THEN** the existing transaction is left exactly as it was #### Scenario: No balance snapshots @@ -197,8 +199,8 @@ feed-authority behavior SHALL remain exclusive to sync. ### Requirement: Rule application after import -The system SHALL apply existing categorization rules to imported transactions once -the import is committed, using the same rule precedence and the same `rule` +The system SHALL apply existing categorization rules to imported transactions +once the import is committed, using the same rule precedence and the same `rule` categorization event source as sync. Imported transactions SHALL NOT introduce a new categorization event source. @@ -219,23 +221,24 @@ new categorization event source. The system SHALL record every import and surface the record in Settings, showing the file name, target account, parsed date range, counts of rows imported and -skipped, and the commit time. Each imported transaction SHALL record which import -produced it. The system SHALL allow a committed import to be undone as a unit, -removing the transactions it produced without deleting any categorization event -history. An undone import SHALL be re-importable after correcting its mapping. +skipped, and the commit time. Each imported transaction SHALL record which +import produced it. The system SHALL allow a committed import to be undone as a +unit, removing the transactions it produced without deleting any categorization +event history. An undone import SHALL be re-importable after correcting its +mapping. #### Scenario: Import listed in settings - **WHEN** a user opens the import record in Settings -- **THEN** each import is listed with its file name, account, date range, counts, - and commit time +- **THEN** each import is listed with its file name, account, date range, + counts, and commit time #### Scenario: Undo removes only that import's rows - **WHEN** a user undoes an import - **THEN** exactly the transactions that import produced are removed from the - ledger and reports, no synced transaction is affected, and the import is marked - undone + ledger and reports, no synced transaction is affected, and the import is + marked undone #### Scenario: Event history survives undo @@ -247,5 +250,5 @@ history. An undone import SHALL be re-importable after correcting its mapping. - **WHEN** a user undoes an import made with a wrong mapping and re-imports the same file with a corrected mapping -- **THEN** the corrected transactions are ingested and the undone import's rows do - not reappear +- **THEN** the corrected transactions are ingested and the undone import's rows + do not reappear diff --git a/openspec/changes/archive/2026-07-16-csv-backfill-import/specs/reporting/spec.md b/openspec/changes/archive/2026-07-16-csv-backfill-import/specs/reporting/spec.md index 3c36c22..722b436 100644 --- a/openspec/changes/archive/2026-07-16-csv-backfill-import/specs/reporting/spec.md +++ b/openspec/changes/archive/2026-07-16-csv-backfill-import/specs/reporting/spec.md @@ -1,32 +1,59 @@ ## MODIFIED Requirements ### Requirement: Transaction ledger -The system SHALL provide a ledger view of transactions filterable by account, category (including uncategorized), month, pending status, and source (synced vs. imported), and searchable by free text. Text search SHALL match case-insensitively against the raw description, payee, and memo fields and against the effective displayed name produced by rule display-name overlays, so search finds what the user sees. Search SHALL compose with all structured filters, and the source filter SHALL compose with all other filters. The ledger shows date, account, displayed name (rule overlay applied when present), amount, category, and a provenance indicator (rule vs. person). The ledger SHALL NOT mark a transaction's source on the row itself; a transaction's origin — synced from a connection, or imported from a named file — SHALL be shown in its history panel. The ledger is the surface for manual categorization and for creating rules from transactions. + +The system SHALL provide a ledger view of transactions filterable by account, +category (including uncategorized), month, pending status, and source (synced +vs. imported), and searchable by free text. Text search SHALL match +case-insensitively against the raw description, payee, and memo fields and +against the effective displayed name produced by rule display-name overlays, so +search finds what the user sees. Search SHALL compose with all structured +filters, and the source filter SHALL compose with all other filters. The ledger +shows date, account, displayed name (rule overlay applied when present), amount, +category, and a provenance indicator (rule vs. person). The ledger SHALL NOT +mark a transaction's source on the row itself; a transaction's origin — synced +from a connection, or imported from a named file — SHALL be shown in its history +panel. The ledger is the surface for manual categorization and for creating +rules from transactions. #### Scenario: Filter to uncategorized + - **WHEN** a user filters the ledger to uncategorized transactions -- **THEN** only transactions with no current category are listed, ready for manual assignment +- **THEN** only transactions with no current category are listed, ready for + manual assignment #### Scenario: Provenance indicator + - **WHEN** a categorized transaction is displayed -- **THEN** the row indicates whether the category came from a rule, a person (with their identity), or reconciliation carry-forward +- **THEN** the row indicates whether the category came from a rule, a person + (with their identity), or reconciliation carry-forward #### Scenario: Search matches a renamed transaction -- **WHEN** a rule renames "ACH TRANSFER 4417" to display as "Rent" and a user searches the ledger for "rent" + +- **WHEN** a rule renames "ACH TRANSFER 4417" to display as "Rent" and a user + searches the ledger for "rent" - **THEN** the transaction is found, even though no raw field contains "rent" #### Scenario: Search composes with filters + - **WHEN** a user searches for "netflix" with a month filter active -- **THEN** only that month's transactions matching the text (raw fields or displayed name) are listed +- **THEN** only that month's transactions matching the text (raw fields or + displayed name) are listed #### Scenario: Filter to imported transactions + - **WHEN** a user filters the ledger by source to imported transactions - **THEN** only transactions produced by a CSV import are listed #### Scenario: Source filter composes -- **WHEN** a user filters by source to imported with an account and month filter active + +- **WHEN** a user filters by source to imported with an account and month filter + active - **THEN** only that account's imported transactions in that month are listed #### Scenario: Origin shown in history -- **WHEN** a user opens the history panel of a transaction that came from a CSV import -- **THEN** the panel states that it was imported and names the file and import date, distinguishing it from a transaction synced from a connection + +- **WHEN** a user opens the history panel of a transaction that came from a CSV + import +- **THEN** the panel states that it was imported and names the file and import + date, distinguishing it from a transaction synced from a connection diff --git a/openspec/changes/archive/2026-07-16-csv-backfill-import/tasks.md b/openspec/changes/archive/2026-07-16-csv-backfill-import/tasks.md index b3e69d0..9db08da 100644 --- a/openspec/changes/archive/2026-07-16-csv-backfill-import/tasks.md +++ b/openspec/changes/archive/2026-07-16-csv-backfill-import/tasks.md @@ -1,144 +1,155 @@ ## 1. Schema -- [x] 1.1 Add `migrations/005_csv_import.sql` creating the `imports` table: `id`, - `account_id` (NOT NULL, FK to accounts), `filename`, `uploaded_at`, `payload` - (verbatim bytes, never mutated), `mapping` (JSON, nullable), `decisions` (JSON, - nullable), `status` NOT NULL DEFAULT `'draft'` CHECK in (`draft`, `committed`, - `undone`), `committed_at`, `undone_at`. -- [x] 1.2 In the same migration, `ALTER TABLE transactions ADD COLUMN import_id - INTEGER REFERENCES imports (id)` (nullable; NULL means synced) and add an index - on `transactions (import_id)`. -- [x] 1.3 Extend `src/lib/server/db.test.ts` to assert the migration applies to a - populated database and that existing transactions read back with - `import_id IS NULL`. +- [x] 1.1 Add `migrations/005_csv_import.sql` creating the `imports` table: + `id`, `account_id` (NOT NULL, FK to accounts), `filename`, `uploaded_at`, + `payload` (verbatim bytes, never mutated), `mapping` (JSON, nullable), + `decisions` (JSON, nullable), `status` NOT NULL DEFAULT `'draft'` CHECK in + (`draft`, `committed`, `undone`), `committed_at`, `undone_at`. +- [x] 1.2 In the same migration, + `ALTER TABLE transactions ADD COLUMN import_id + INTEGER REFERENCES imports (id)` + (nullable; NULL means synced) and add an index on + `transactions (import_id)`. +- [x] 1.3 Extend `src/lib/server/db.test.ts` to assert the migration applies to + a populated database and that existing transactions read back with + `import_id IS NULL`. ## 2. CSV parsing and mapping (pure) - [x] 2.1 Add `csv-parse` (npm) as a dependency. Do not hand-roll parsing — - quoted delimiters, embedded newlines, and BOMs are the failure modes. An npm - package rather than JSR's `@std/csv`: Vite-under-Deno resolves a JSR specifier - fine, but `svelte-check` resolves Node-style and cannot, which would have left - `deno task check` permanently red. + quoted delimiters, embedded newlines, and BOMs are the failure modes. An + npm package rather than JSR's `@std/csv`: Vite-under-Deno resolves a JSR + specifier fine, but `svelte-check` resolves Node-style and cannot, which + would have left `deno task check` permanently red. - [x] 2.2 Create `src/lib/server/services/csv-import.ts` with a pure - `parseCsv(payloadText)` returning header row plus data rows, tolerating a BOM - and CRLF line endings. + `parseCsv(payloadText)` returning header row plus data rows, tolerating a + BOM and CRLF line endings. - [x] 2.3 Implement pure `detectMapping(headers)` returning best-guess column - assignments for date, amount (single signed column or debit/credit pair), and - description, plus optional payee/memo and a distinct transaction-date column. + assignments for date, amount (single signed column or debit/credit pair), + and description, plus optional payee/memo and a distinct transaction-date + column. - [x] 2.4 Implement pure `normalizeCsvAmount(raw)` handling parenthesized - negatives `(12.34)`, trailing minus `12.34-`, currency symbols, and thousands - separators, emitting a signed decimal string for the existing - `parseAmountToCents`. Do not loosen `parseAmountToCents` itself — its strict - regex is the sync path's contract. -- [x] 2.5 Implement pure `normalizeCsv(payloadText, mapping, accountId)` producing - candidate rows with `posted` set from the file's date, `pending = 0`, - `transacted_at` set only when the mapping names a distinct transaction-date - column, and amounts as integer cents. + negatives `(12.34)`, trailing minus `12.34-`, currency symbols, and + thousands separators, emitting a signed decimal string for the existing + `parseAmountToCents`. Do not loosen `parseAmountToCents` itself — its + strict regex is the sync path's contract. +- [x] 2.5 Implement pure `normalizeCsv(payloadText, mapping, accountId)` + producing candidate rows with `posted` set from the file's date, + `pending = 0`, `transacted_at` set only when the mapping names a distinct + transaction-date column, and amounts as integer cents. - [x] 2.6 Implement the synthetic identity: `csv:` + hash over (accountId, date, - amountCents, description) plus an occurrence index among identical rows within - the file. -- [x] 2.7 Unit-test 2.2–2.6 in `csv-import.test.ts`: BOM/CRLF, both amount modes, - each negative convention, date parsing, and that two identical rows receive - distinct ids while a reordered or superset file re-derives the same id set. + amountCents, description) plus an occurrence index among identical rows + within the file. +- [x] 2.7 Unit-test 2.2–2.6 in `csv-import.test.ts`: BOM/CRLF, both amount + modes, each negative convention, date parsing, and that two identical rows + receive distinct ids while a reordered or superset file re-derives the + same id set. ## 3. Duplicate detection -- [x] 3.1 Implement `findPotentialDuplicates(db, accountId, candidates)` matching - each candidate against non-removed transactions in the account by identical - `amount_cents` and effective date within ±1 day (compare against - `COALESCE(posted, transacted_at)`). Never match on description. +- [x] 3.1 Implement `findPotentialDuplicates(db, accountId, candidates)` + matching each candidate against non-removed transactions in the account by + identical `amount_cents` and effective date within ±1 day (compare against + `COALESCE(posted, transacted_at)`). Never match on description. - [x] 3.2 Return, per candidate, one of: `new`, `already-present` (its synthetic - id already exists — never surfaced to the user), or `flagged` with the existing - transaction's date, amount, and description for side-by-side display. + id already exists — never surfaced to the user), or `flagged` with the + existing transaction's date, amount, and description for side-by-side + display. - [x] 3.3 Test: a cross-source duplicate with differently worded descriptions is - flagged; a row one day off is flagged; a row two days off is not; an - already-ingested row reports `already-present` rather than `flagged`. + flagged; a row one day off is flagged; a row two days off is not; an + already-ingested row reports `already-present` rather than `flagged`. ## 4. Import lifecycle and additive ingestion -- [x] 4.1 Implement `createDraftImport(db, accountId, filename, payload)` writing - the `imports` row with verbatim payload and `status = 'draft'` before any - parsing. -- [x] 4.2 Implement `saveMapping` and `saveDecisions` persisting to the draft row, - so the archived record alone determines the outcome. -- [x] 4.3 Implement `commitImport(db, importId)` — an **insert-only** writer. - It MUST NOT reconcile, MUST NOT sweep stale pending rows, MUST NOT update - existing transactions, MUST NOT write balance snapshots, and MUST NOT touch - account state. Do not call or extend `ingestTransactions`; its stale-pending - sweep (`sync.ts:291-301`) would soft-delete every pending row absent from the - CSV. +- [x] 4.1 Implement `createDraftImport(db, accountId, filename, payload)` + writing the `imports` row with verbatim payload and `status = 'draft'` + before any parsing. +- [x] 4.2 Implement `saveMapping` and `saveDecisions` persisting to the draft + row, so the archived record alone determines the outcome. +- [x] 4.3 Implement `commitImport(db, importId)` — an **insert-only** writer. It + MUST NOT reconcile, MUST NOT sweep stale pending rows, MUST NOT update + existing transactions, MUST NOT write balance snapshots, and MUST NOT + touch account state. Do not call or extend `ingestTransactions`; its + stale-pending sweep (`sync.ts:291-301`) would soft-delete every pending + row absent from the CSV. - [x] 4.4 In `commitImport`, skip candidates marked skipped and those whose - synthetic id already exists; revive a soft-removed row matching the unique key - by clearing `removed_at` and reassigning `import_id`; stamp `import_id` on every - inserted row; set `status = 'committed'` and `committed_at`. Run the whole - commit in one transaction. + synthetic id already exists; revive a soft-removed row matching the unique + key by clearing `removed_at` and reassigning `import_id`; stamp + `import_id` on every inserted row; set `status = 'committed'` and + `committed_at`. Run the whole commit in one transaction. - [x] 4.5 Call `applyRulesToUncategorized(db)` after the commit transaction - succeeds. No new categorization event source. + succeeds. No new categorization event source. - [x] 4.6 Test: importing into an account holding pending rows leaves them - untouched; no snapshots are written; account state and - `last_successful_data_at` are unchanged; re-importing the same file inserts - nothing; an overlapping file inserts only the new rows; committed rows carry - `import_id` and are picked up by rules. + untouched; no snapshots are written; account state and + `last_successful_data_at` are unchanged; re-importing the same file + inserts nothing; an overlapping file inserts only the new rows; committed + rows carry `import_id` and are picked up by rules. ## 5. Undo -- [x] 5.1 Implement `undoImport(db, importId)` soft-removing (`removed_at = now`) - every transaction with that `import_id`, setting `status = 'undone'` and - `undone_at`, in one transaction. Never hard-delete — categorization events - reference `transaction_id` and the event log is append-only. +- [x] 5.1 Implement `undoImport(db, importId)` soft-removing + (`removed_at = now`) every transaction with that `import_id`, setting + `status = 'undone'` and `undone_at`, in one transaction. Never hard-delete + — categorization events reference `transaction_id` and the event log is + append-only. - [x] 5.2 Test: undo removes exactly that import's rows from ledger and report - scope, leaves synced rows untouched, preserves categorization events for the - removed rows, and a corrected re-import afterwards does not resurrect the old - rows. + scope, leaves synced rows untouched, preserves categorization events for + the removed rows, and a corrected re-import afterwards does not resurrect + the old rows. ## 6. Ledger surfacing - [x] 6.1 Add `source?: 'synced' | 'imported'` to `LedgerFilters` in - `src/lib/server/services/ledger.ts`, translating to `t.import_id IS NULL` / - `IS NOT NULL`, composing with every existing filter. -- [x] 6.2 Add origin to `LedgerRow` (import id, file name, import date; null when - synced) via a join through `import_id`. + `src/lib/server/services/ledger.ts`, translating to `t.import_id IS NULL` + / `IS NOT NULL`, composing with every existing filter. +- [x] 6.2 Add origin to `LedgerRow` (import id, file name, import date; null + when synced) via a join through `import_id`. - [x] 6.3 Test in `ledger.test.ts`: the source filter composes with account, - month, category, pending, and text search. -- [x] 6.4 Add the source filter control to the ledger page alongside the existing - filters. Add no per-row badge — within a backfilled range every row is imported, - so a per-row mark carries no information where it appears. + month, category, pending, and text search. +- [x] 6.4 Add the source filter control to the ledger page alongside the + existing filters. Add no per-row badge — within a backfilled range every + row is imported, so a per-row mark carries no information where it + appears. - [x] 6.5 Add an origin line to the top of the existing history panel: "Imported - from `` · ``" or "Synced from SimpleFIN". + from `` · ``" or "Synced from SimpleFIN". ## 7. Import wizard (Settings) - [x] 7.1 Add a Settings route for the import wizard. Step 1: file upload plus - target-account picker (non-hidden accounts only), archiving on submit and - redirecting to the draft's id. + target-account picker (non-hidden accounts only), archiving on submit and + redirecting to the draft's id. - [x] 7.2 Step 2: mapping confirmation, pre-filled from `detectMapping`, every - field reassignable, with a few parsed sample rows rendered so a wrong guess is - visible. + field reassignable, with a few parsed sample rows rendered so a wrong + guess is visible. - [x] 7.3 Step 3: preview stating the parsed date range, rows to import, rows - already present, and rows flagged — the guard against a catastrophically wrong - date mapping. + already present, and rows flagged — the guard against a catastrophically + wrong date mapping. - [x] 7.4 Step 4: duplicate review listing each flagged row against its existing - match, both descriptions shown, defaulting to skip, each flippable to keep. + match, both descriptions shown, defaulting to skip, each flippable to + keep. - [x] 7.5 Commit action, then a result summary linking to the ledger filtered to - that account and source. -- [x] 7.6 Style per DESIGN.md: tabular numerals on money and dates, calm empty and - error states (one sentence plus one action), no new red unless data is at risk. + that account and source. +- [x] 7.6 Style per DESIGN.md: tabular numerals on money and dates, calm empty + and error states (one sentence plus one action), no new red unless data is + at risk. ## 8. Import record (Settings) -- [x] 8.1 Add a Settings section listing imports: file name, account, parsed date - range, rows imported, rows skipped, commit time, status. Not in the main nav. +- [x] 8.1 Add a Settings section listing imports: file name, account, parsed + date range, rows imported, rows skipped, commit time, status. Not in the + main nav. - [x] 8.2 Add the undo action with a confirmation stating exactly how many - transactions will be removed. + transactions will be removed. - [x] 8.3 Render an empty state for the no-imports-yet case. ## 9. Verification - [x] 9.1 Run `deno task test` and `deno task check`. - [x] 9.2 Drive the wizard end to end against a real bank CSV export in dev: - confirm the gap closes in the ledger, the month reports for backfilled months - change as expected, and the net worth chart is unchanged (no snapshots written). + confirm the gap closes in the ledger, the month reports for backfilled + months change as expected, and the net worth chart is unchanged (no + snapshots written). - [x] 9.3 Verify a sync runs cleanly after an import: pending rows still - reconcile, and no imported row is disturbed. -- [x] 9.4 Exercise undo on a real import and confirm the ledger and reports return - to their pre-import state. + reconcile, and no imported row is disturbed. +- [x] 9.4 Exercise undo on a real import and confirm the ledger and reports + return to their pre-import state. diff --git a/openspec/specs/account-management/spec.md b/openspec/specs/account-management/spec.md index 407e3ae..8b2d683 100644 --- a/openspec/specs/account-management/spec.md +++ b/openspec/specs/account-management/spec.md @@ -1,49 +1,88 @@ # account-management Specification ## Purpose -TBD - created by archiving change bootstrap-finance-app. Update Purpose after archive. + +TBD - created by archiving change bootstrap-finance-app. Update Purpose after +archive. + ## Requirements + ### Requirement: Account discovery from sync feed -The system SHALL create account records only from sync data, never via in-app creation. An account id appearing in a sync for the first time SHALL be registered in state `NEW` with its SimpleFIN-provided organization, name, and currency. + +The system SHALL create account records only from sync data, never via in-app +creation. An account id appearing in a sync for the first time SHALL be +registered in state `NEW` with its SimpleFIN-provided organization, name, and +currency. #### Scenario: Unknown account appears in sync + - **WHEN** a sync payload contains an account id not present in the database -- **THEN** an account row is created in state `NEW` and its transactions and balance snapshots are ingested normally +- **THEN** an account row is created in state `NEW` and its transactions and + balance snapshots are ingested normally ### Requirement: Account classification -The system SHALL require one-time user classification of each `NEW` account before it is treated as `ACTIVE`: the user assigns an account type (e.g., checking, savings, credit card, investment) and may set a friendly display name. SimpleFIN provides no account type, so classification is user-supplied. The UI SHALL surface accounts awaiting classification. + +The system SHALL require one-time user classification of each `NEW` account +before it is treated as `ACTIVE`: the user assigns an account type (e.g., +checking, savings, credit card, investment) and may set a friendly display name. +SimpleFIN provides no account type, so classification is user-supplied. The UI +SHALL surface accounts awaiting classification. #### Scenario: User classifies a new account + - **WHEN** a user assigns a type (and optional display name) to a `NEW` account - **THEN** the account transitions to `ACTIVE` and appears in reporting #### Scenario: Unclassified account visibility + - **WHEN** any account is in state `NEW` - **THEN** the dashboard shows a prompt to classify it ### Requirement: Account lifecycle states -The system SHALL track account states `NEW`, `ACTIVE`, `INACTIVE`, and `HIDDEN`. An account that stops appearing in sync feeds SHALL transition to `INACTIVE` automatically; its transactions, categorizations, and snapshots SHALL be retained. A user MAY mark an account `HIDDEN` to exclude it from all reporting without deleting data, and MAY unhide it later. The system SHALL never delete account history. + +The system SHALL track account states `NEW`, `ACTIVE`, `INACTIVE`, and `HIDDEN`. +An account that stops appearing in sync feeds SHALL transition to `INACTIVE` +automatically; its transactions, categorizations, and snapshots SHALL be +retained. A user MAY mark an account `HIDDEN` to exclude it from all reporting +without deleting data, and MAY unhide it later. The system SHALL never delete +account history. #### Scenario: Account vanishes from feed -- **WHEN** an `ACTIVE` account is absent from a successful sync of its connection -- **THEN** the account transitions to `INACTIVE` and all historical data remains queryable + +- **WHEN** an `ACTIVE` account is absent from a successful sync of its + connection +- **THEN** the account transitions to `INACTIVE` and all historical data remains + queryable #### Scenario: Inactive account reappears + - **WHEN** an `INACTIVE` account id appears in a sync again -- **THEN** the account returns to `ACTIVE` (or `NEW` if it was never classified) and ingestion resumes +- **THEN** the account returns to `ACTIVE` (or `NEW` if it was never classified) + and ingestion resumes #### Scenario: User hides an account + - **WHEN** a user marks an account `HIDDEN` -- **THEN** the account and its transactions are excluded from reports until unhidden, and no data is deleted +- **THEN** the account and its transactions are excluded from reports until + unhidden, and no data is deleted ### Requirement: Connection health surfacing -The system SHALL surface connection errors returned in sync responses and per-account staleness. Each account SHALL track `last_successful_data_at`. When a sync reports connection-level errors or an account's data is stale beyond a threshold, the dashboard SHALL display a banner identifying the institution and directing the user to resolve it at SimpleFIN Bridge (the app cannot repair connections). + +The system SHALL surface connection errors returned in sync responses and +per-account staleness. Each account SHALL track `last_successful_data_at`. When +a sync reports connection-level errors or an account's data is stale beyond a +threshold, the dashboard SHALL display a banner identifying the institution and +directing the user to resolve it at SimpleFIN Bridge (the app cannot repair +connections). #### Scenario: Sync response contains a connection error + - **WHEN** a sync response includes an error for an institution -- **THEN** the dashboard shows a banner naming the institution with a link out to SimpleFIN Bridge +- **THEN** the dashboard shows a banner naming the institution with a link out + to SimpleFIN Bridge #### Scenario: Account data goes stale -- **WHEN** an `ACTIVE` account's `last_successful_data_at` exceeds the staleness threshold -- **THEN** the dashboard indicates the account has not updated since that time +- **WHEN** an `ACTIVE` account's `last_successful_data_at` exceeds the staleness + threshold +- **THEN** the dashboard indicates the account has not updated since that time diff --git a/openspec/specs/auth/spec.md b/openspec/specs/auth/spec.md index aa42b75..8ff9711 100644 --- a/openspec/specs/auth/spec.md +++ b/openspec/specs/auth/spec.md @@ -1,74 +1,137 @@ # auth Specification ## Purpose -TBD - created by archiving change bootstrap-finance-app. Update Purpose after archive. + +TBD - created by archiving change bootstrap-finance-app. Update Purpose after +archive. + ## Requirements + ### Requirement: ATProto OAuth login -The system SHALL authenticate users via AT Protocol OAuth using handle-based login. The user enters their handle (or DID); the system resolves it, performs the OAuth authorization flow (PAR, PKCE, DPoP) against the user's authorization server, and establishes an application session on success. The system SHALL request only the `atproto` scope and SHALL NOT make authenticated requests to the user's PDS after authentication. Resolving and rendering profile pictures from public ATProto profile data — without using the OAuth session or any application credential — is permitted. + +The system SHALL authenticate users via AT Protocol OAuth using handle-based +login. The user enters their handle (or DID); the system resolves it, performs +the OAuth authorization flow (PAR, PKCE, DPoP) against the user's authorization +server, and establishes an application session on success. The system SHALL +request only the `atproto` scope and SHALL NOT make authenticated requests to +the user's PDS after authentication. Resolving and rendering profile pictures +from public ATProto profile data — without using the OAuth session or any +application credential — is permitted. #### Scenario: Successful login with allowlisted handle + - **WHEN** a user whose DID is in the allowlist completes the OAuth flow -- **THEN** the system creates an application session and sets an HTTP-only, Secure session cookie +- **THEN** the system creates an application session and sets an HTTP-only, + Secure session cookie - **AND** the user is redirected to the dashboard #### Scenario: Login with unknown handle + - **WHEN** a user submits a handle that cannot be resolved to a DID -- **THEN** the system shows an error on the login page without starting the OAuth flow +- **THEN** the system shows an error on the login page without starting the + OAuth flow ### Requirement: DID allowlist authorization -The system SHALL authorize users solely by membership in the `ALLOWED_DIDS` environment variable. A successful OAuth authentication with a DID not in the allowlist SHALL be rejected and SHALL NOT create a session or a user record. + +The system SHALL authorize users solely by membership in the `ALLOWED_DIDS` +environment variable. A successful OAuth authentication with a DID not in the +allowlist SHALL be rejected and SHALL NOT create a session or a user record. #### Scenario: Non-allowlisted DID completes OAuth + - **WHEN** the OAuth callback resolves to a DID not present in `ALLOWED_DIDS` -- **THEN** the system responds with 403 and a message that the account is not authorized +- **THEN** the system responds with 403 and a message that the account is not + authorized - **AND** no session or user record is created #### Scenario: Allowlisted DID first login + - **WHEN** an allowlisted DID logs in for the first time - **THEN** the system creates a user record storing the DID and current handle ### Requirement: Env-derived client identity -The system SHALL derive its OAuth client identity entirely from the `APP_URL` environment variable: the client metadata document SHALL be served at `{APP_URL}/client-metadata.json`, the JWKS at `{APP_URL}/jwks.json`, and the redirect URI SHALL be `{APP_URL}/oauth/callback`. Both documents SHALL be generated dynamically at request time from live configuration, not served as static files. The client SHALL be a confidential client using `private_key_jwt` with DPoP-bound tokens. + +The system SHALL derive its OAuth client identity entirely from the `APP_URL` +environment variable: the client metadata document SHALL be served at +`{APP_URL}/client-metadata.json`, the JWKS at `{APP_URL}/jwks.json`, and the +redirect URI SHALL be `{APP_URL}/oauth/callback`. Both documents SHALL be +generated dynamically at request time from live configuration, not served as +static files. The client SHALL be a confidential client using `private_key_jwt` +with DPoP-bound tokens. #### Scenario: Client metadata reflects APP_URL + - **WHEN** `GET /client-metadata.json` is requested -- **THEN** the response is `application/json` with `client_id` equal to `{APP_URL}/client-metadata.json`, `redirect_uris` containing `{APP_URL}/oauth/callback`, `token_endpoint_auth_method` of `private_key_jwt`, `dpop_bound_access_tokens` true, and scope including `atproto` +- **THEN** the response is `application/json` with `client_id` equal to + `{APP_URL}/client-metadata.json`, `redirect_uris` containing + `{APP_URL}/oauth/callback`, `token_endpoint_auth_method` of `private_key_jwt`, + `dpop_bound_access_tokens` true, and scope including `atproto` #### Scenario: JWKS served for client authentication + - **WHEN** `GET /jwks.json` is requested -- **THEN** the response contains the public JWK(s) corresponding to the configured OAuth signing key +- **THEN** the response contains the public JWK(s) corresponding to the + configured OAuth signing key ### Requirement: Session management -The system SHALL persist application sessions and OAuth client state (state store, session store) in SQLite. Sessions SHALL be identified by opaque tokens, delivered to the web frontend via HTTP-only cookie; the token format SHALL NOT assume cookie transport, so future native clients can present the same token as a bearer credential. Every route except login, the OAuth callback, client metadata, and JWKS SHALL require a valid session. Users SHALL be able to log out, which destroys the application session. + +The system SHALL persist application sessions and OAuth client state (state +store, session store) in SQLite. Sessions SHALL be identified by opaque tokens, +delivered to the web frontend via HTTP-only cookie; the token format SHALL NOT +assume cookie transport, so future native clients can present the same token as +a bearer credential. Every route except login, the OAuth callback, client +metadata, and JWKS SHALL require a valid session. Users SHALL be able to log +out, which destroys the application session. #### Scenario: Unauthenticated access to a protected route + - **WHEN** a request without a valid session cookie targets any protected route - **THEN** the system redirects to the login page #### Scenario: Logout + - **WHEN** an authenticated user triggers logout -- **THEN** the session record is deleted, the cookie is cleared, and subsequent requests are treated as unauthenticated +- **THEN** the session record is deleted, the cookie is cleared, and subsequent + requests are treated as unauthenticated #### Scenario: Session survives server restart -- **WHEN** the server process restarts and a user presents a previously issued valid session cookie + +- **WHEN** the server process restarts and a user presents a previously issued + valid session cookie - **THEN** the session is honored because it is persisted in SQLite ### Requirement: Profile picture display -The system SHALL display user profile pictures resolved from public ATProto profile data, keyed by the user's current handle and fetched by the browser without application credentials. Avatars SHALL appear in the navigation user chip alongside the handle, and in categorization provenance displays in place of the handle text, where the handle SHALL be revealed in a popover on hover or keyboard focus and the DID SHALL remain available via an accessible label. When an avatar cannot be loaded or does not exist, the UI SHALL fall back to the textual handle presentation. + +The system SHALL display user profile pictures resolved from public ATProto +profile data, keyed by the user's current handle and fetched by the browser +without application credentials. Avatars SHALL appear in the navigation user +chip alongside the handle, and in categorization provenance displays in place of +the handle text, where the handle SHALL be revealed in a popover on hover or +keyboard focus and the DID SHALL remain available via an accessible label. When +an avatar cannot be loaded or does not exist, the UI SHALL fall back to the +textual handle presentation. #### Scenario: Nav chip shows avatar + - **WHEN** an authenticated user views any app page -- **THEN** the navigation user chip shows their avatar together with their handle +- **THEN** the navigation user chip shows their avatar together with their + handle #### Scenario: Provenance avatar with popover -- **WHEN** a user hovers over or keyboard-focuses the actor avatar in a transaction's categorization history -- **THEN** a popover reveals the actor's handle, and the DID is exposed via an accessible label + +- **WHEN** a user hovers over or keyboard-focuses the actor avatar in a + transaction's categorization history +- **THEN** a popover reveals the actor's handle, and the DID is exposed via an + accessible label #### Scenario: Avatar unavailable + - **WHEN** an avatar image fails to load or the actor has no profile picture -- **THEN** the UI renders the actor's handle as text, and no provenance information is lost +- **THEN** the UI renders the actor's handle as text, and no provenance + information is lost #### Scenario: Server fetches no profile data -- **WHEN** pages containing avatars are rendered -- **THEN** all profile-image requests originate from the browser against public endpoints, and the application server performs no profile-data requests +- **WHEN** pages containing avatars are rendered +- **THEN** all profile-image requests originate from the browser against public + endpoints, and the application server performs no profile-data requests diff --git a/openspec/specs/categorization/spec.md b/openspec/specs/categorization/spec.md index 57788c3..3b1fa99 100644 --- a/openspec/specs/categorization/spec.md +++ b/openspec/specs/categorization/spec.md @@ -1,120 +1,228 @@ -# categorization Specification - -## Purpose -TBD - created by archiving change bootstrap-finance-app. Update Purpose after archive. -## Requirements -### Requirement: Category management -The system SHALL provide flat categories with a name and a kind of `income`, `expense`, or `transfer`. Users SHALL be able to create, rename, and deactivate categories. A built-in `Transfer` category (kind `transfer`) SHALL exist from initial migration and SHALL NOT be deletable. Transactions SHALL reference categories directly (never any grouping construct), keeping future category grouping purely additive. - -#### Scenario: Create a category -- **WHEN** a user creates a category with a name and kind -- **THEN** the category is available for rules and manual assignment - -#### Scenario: Built-in Transfer category is protected -- **WHEN** a user attempts to delete the built-in Transfer category -- **THEN** the system refuses - -### Requirement: Append-only categorization event log -The system SHALL record every category assignment as an immutable event: transaction id, category id, source (`rule`, `manual`, or `reconciliation`), the rule id for rule events, the acting user's DID for manual events, and a timestamp. A transaction's current category SHALL be the latest event, denormalized onto the transaction row in the same database transaction as the event insert. Events SHALL never be updated or deleted. - -#### Scenario: Manual categorization records actor -- **WHEN** an authenticated user assigns a category to a transaction -- **THEN** a `manual` event is appended with that user's DID and the transaction's cached category is updated atomically with it - -#### Scenario: Provenance is visible -- **WHEN** a user views a categorized transaction's history -- **THEN** the UI shows every event in order: what assigned it (rule pattern or person), to which category, and when - -#### Scenario: Recategorization preserves history -- **WHEN** a user changes an already-categorized transaction to a different category -- **THEN** a new event is appended and prior events remain queryable - -### Requirement: Rule-based auto-categorization -The system SHALL support categorization rules with match types `exact` and `contains`, matched case-insensitively against the raw transaction description and, when the provider supplies them, the payee and memo fields (a rule fires if any of these matches). A rule MAY additionally specify an exact amount in signed integer cents; such a rule fires only when the text pattern matches AND the transaction's amount equals the rule's amount exactly. A text pattern is always required — amount-only rules SHALL NOT be permitted. Rules record their creator's DID and creation time. When multiple rules match one transaction, precedence SHALL be deterministic: amount-constrained beats unconstrained, then `exact` beats `contains`, then longer pattern beats shorter, then newer rule beats older. Rule application SHALL append a `rule` event recording the winning rule's id. - -#### Scenario: Rule fires on new transaction at sync -- **WHEN** a sync ingests an uncategorized transaction whose description matches an active rule -- **THEN** the winning rule's category is applied via a `rule` event referencing that rule - -#### Scenario: Precedence between overlapping rules -- **WHEN** a description matches both `contains "AMAZON"` and `contains "AMAZON PRIME"` -- **THEN** the longer pattern's rule wins and the fired rule id is recorded on the event - -#### Scenario: Rule matches the payee or memo field -- **WHEN** a transaction's description is a terse bank label but its provider-supplied payee or memo matches an active rule -- **THEN** the rule fires exactly as if the description had matched - -#### Scenario: Amount conjunct narrows a match -- **WHEN** a transaction matches a rule's text pattern but its amount differs from the rule's specified amount -- **THEN** that rule does not fire for the transaction - -#### Scenario: Amount-constrained rule outranks unconstrained -- **WHEN** a transaction matches both a `contains` rule with a matching amount constraint and an `exact` rule with no amount constraint -- **THEN** the amount-constrained rule wins and its id is recorded on the event - -### Requirement: Manual decisions outrank rules -The system SHALL never allow a rule to overwrite a categorization whose latest event is `manual`. Rules fire only on transactions with no current category. When a user creates a rule, the system SHALL offer to retroactively apply it to existing matching transactions that are currently uncategorized, and SHALL NOT touch categorized ones. - -#### Scenario: Rule does not overwrite manual choice -- **WHEN** a rule matching a transaction is created or runs, and that transaction's latest event is `manual` -- **THEN** the transaction's category is unchanged - -#### Scenario: Retroactive application on rule creation -- **WHEN** a user creates a rule and accepts the retroactive-apply offer -- **THEN** the rule is applied to all matching currently-uncategorized transactions, each receiving a `rule` event - -### Requirement: Rule display-name overlay -A rule MAY specify a display name. Wherever the system displays a transaction whose current category was assigned by that rule, it SHALL show the rule's display name in place of the raw payee/description. The overlay SHALL be applied at read time: the stored transaction fields remain canonical and unmodified, no per-transaction rename events are recorded, and changing or deactivating the rule's display name SHALL be reflected everywhere immediately. The raw description SHALL remain accessible in the transaction's detail view. - -#### Scenario: Renamed transaction display -- **WHEN** a rule with display name "Netflix" categorized a transaction described "NETFLIX.COM 866-579-7172" -- **THEN** ledger and dashboard listings show "Netflix", and the transaction's detail view still shows the raw description - -#### Scenario: Rule edit re-renames instantly -- **WHEN** a user changes a rule's display name -- **THEN** every transaction currently categorized by that rule reflects the new name on next render, with no data migration or new events - -#### Scenario: Manual recategorization sheds the overlay -- **WHEN** a user manually recategorizes a transaction that a renaming rule had categorized -- **THEN** the transaction's displayed name reverts to its raw payee/description, consistent with the rule no longer being its provenance - -### Requirement: Rule management page -The system SHALL provide a dedicated rules page, linked from the primary navigation, replacing rule management in Settings. The page SHALL list all rules with their pattern, match type, amount constraint, display name, target category, and active state, and SHALL provide rule search over these fields. Rule creation, enable, and disable SHALL be available from this page with unchanged semantics (including the retroactive-apply offer on creation). - -#### Scenario: Rules relocated -- **WHEN** a user opens Settings -- **THEN** rule management is no longer present, and the rules page is reachable from the primary navigation - -#### Scenario: Rule search -- **WHEN** a user searches the rules page for "netflix" -- **THEN** rules whose pattern, display name, or category name match are listed - -### Requirement: Rule inspection -The system SHALL provide a per-rule detail view showing: (1) what the rule did — transactions whose categorization events reference the rule, from the append-only log; (2) what the rule would hit — a live, read-only probe across all non-removed transactions of non-hidden accounts, each hit labeled with its current categorization status (uncategorized, this rule, another rule, or manual); and (3) match health — when the rule last fired, and for amount-constrained rules, recent transactions that matched the text pattern but not the amount. The probe SHALL NOT modify any transaction or event. - -#### Scenario: Historical fires -- **WHEN** a user opens a rule's detail view -- **THEN** transactions the rule categorized are listed from the event log, including ones later recategorized - -#### Scenario: Prospective matches labeled -- **WHEN** a user views a rule's would-hit preview -- **THEN** matching transactions are shown with their current status, and none are modified - -#### Scenario: Amount drift surfaced -- **WHEN** an amount-constrained rule's text pattern matches recent transactions at a different amount -- **THEN** the detail view surfaces those transactions as pattern-hit/amount-miss, indicating a probable price change - -### Requirement: Rule creation from the ledger -The system SHALL allow creating a rule from any ledger transaction via a slide-over tray, without leaving the ledger. The tray SHALL be pre-filled from the transaction: its payee (preferred) or description as the pattern, with the transaction's amount available as an opt-in constraint and an optional display name. When opened from an active ledger search, the search term SHALL be offered as the pattern. Saving SHALL create the rule with unchanged creation semantics, including the offer to retroactively apply it to matching uncategorized transactions. - -#### Scenario: Create rule from a transaction -- **WHEN** a user opens the rule tray from a ledger row and saves -- **THEN** a rule is created pre-filled from that transaction without navigating away, and the retroactive-apply offer is presented - -#### Scenario: Amount opt-in -- **WHEN** a user opens the rule tray from a transaction -- **THEN** the amount constraint is offered but not enabled by default - -#### Scenario: Search term becomes pattern -- **WHEN** a user opens the rule tray while a ledger text search is active -- **THEN** the search term is pre-filled as the rule pattern +# categorization Specification + +## Purpose + +TBD - created by archiving change bootstrap-finance-app. Update Purpose after +archive. + +## Requirements + +### Requirement: Category management + +The system SHALL provide flat categories with a name and a kind of `income`, +`expense`, or `transfer`. Users SHALL be able to create, rename, and deactivate +categories. A built-in `Transfer` category (kind `transfer`) SHALL exist from +initial migration and SHALL NOT be deletable. Transactions SHALL reference +categories directly (never any grouping construct), keeping future category +grouping purely additive. + +#### Scenario: Create a category + +- **WHEN** a user creates a category with a name and kind +- **THEN** the category is available for rules and manual assignment + +#### Scenario: Built-in Transfer category is protected + +- **WHEN** a user attempts to delete the built-in Transfer category +- **THEN** the system refuses + +### Requirement: Append-only categorization event log + +The system SHALL record every category assignment as an immutable event: +transaction id, category id, source (`rule`, `manual`, or `reconciliation`), the +rule id for rule events, the acting user's DID for manual events, and a +timestamp. A transaction's current category SHALL be the latest event, +denormalized onto the transaction row in the same database transaction as the +event insert. Events SHALL never be updated or deleted. + +#### Scenario: Manual categorization records actor + +- **WHEN** an authenticated user assigns a category to a transaction +- **THEN** a `manual` event is appended with that user's DID and the + transaction's cached category is updated atomically with it + +#### Scenario: Provenance is visible + +- **WHEN** a user views a categorized transaction's history +- **THEN** the UI shows every event in order: what assigned it (rule pattern or + person), to which category, and when + +#### Scenario: Recategorization preserves history + +- **WHEN** a user changes an already-categorized transaction to a different + category +- **THEN** a new event is appended and prior events remain queryable + +### Requirement: Rule-based auto-categorization + +The system SHALL support categorization rules with match types `exact` and +`contains`, matched case-insensitively against the raw transaction description +and, when the provider supplies them, the payee and memo fields (a rule fires if +any of these matches). A rule MAY additionally specify an exact amount in signed +integer cents; such a rule fires only when the text pattern matches AND the +transaction's amount equals the rule's amount exactly. A text pattern is always +required — amount-only rules SHALL NOT be permitted. Rules record their +creator's DID and creation time. When multiple rules match one transaction, +precedence SHALL be deterministic: amount-constrained beats unconstrained, then +`exact` beats `contains`, then longer pattern beats shorter, then newer rule +beats older. Rule application SHALL append a `rule` event recording the winning +rule's id. + +#### Scenario: Rule fires on new transaction at sync + +- **WHEN** a sync ingests an uncategorized transaction whose description matches + an active rule +- **THEN** the winning rule's category is applied via a `rule` event referencing + that rule + +#### Scenario: Precedence between overlapping rules + +- **WHEN** a description matches both `contains "AMAZON"` and + `contains "AMAZON PRIME"` +- **THEN** the longer pattern's rule wins and the fired rule id is recorded on + the event + +#### Scenario: Rule matches the payee or memo field + +- **WHEN** a transaction's description is a terse bank label but its + provider-supplied payee or memo matches an active rule +- **THEN** the rule fires exactly as if the description had matched + +#### Scenario: Amount conjunct narrows a match + +- **WHEN** a transaction matches a rule's text pattern but its amount differs + from the rule's specified amount +- **THEN** that rule does not fire for the transaction + +#### Scenario: Amount-constrained rule outranks unconstrained + +- **WHEN** a transaction matches both a `contains` rule with a matching amount + constraint and an `exact` rule with no amount constraint +- **THEN** the amount-constrained rule wins and its id is recorded on the event + +### Requirement: Manual decisions outrank rules + +The system SHALL never allow a rule to overwrite a categorization whose latest +event is `manual`. Rules fire only on transactions with no current category. +When a user creates a rule, the system SHALL offer to retroactively apply it to +existing matching transactions that are currently uncategorized, and SHALL NOT +touch categorized ones. + +#### Scenario: Rule does not overwrite manual choice + +- **WHEN** a rule matching a transaction is created or runs, and that + transaction's latest event is `manual` +- **THEN** the transaction's category is unchanged + +#### Scenario: Retroactive application on rule creation + +- **WHEN** a user creates a rule and accepts the retroactive-apply offer +- **THEN** the rule is applied to all matching currently-uncategorized + transactions, each receiving a `rule` event + +### Requirement: Rule display-name overlay + +A rule MAY specify a display name. Wherever the system displays a transaction +whose current category was assigned by that rule, it SHALL show the rule's +display name in place of the raw payee/description. The overlay SHALL be applied +at read time: the stored transaction fields remain canonical and unmodified, no +per-transaction rename events are recorded, and changing or deactivating the +rule's display name SHALL be reflected everywhere immediately. The raw +description SHALL remain accessible in the transaction's detail view. + +#### Scenario: Renamed transaction display + +- **WHEN** a rule with display name "Netflix" categorized a transaction + described "NETFLIX.COM 866-579-7172" +- **THEN** ledger and dashboard listings show "Netflix", and the transaction's + detail view still shows the raw description + +#### Scenario: Rule edit re-renames instantly + +- **WHEN** a user changes a rule's display name +- **THEN** every transaction currently categorized by that rule reflects the new + name on next render, with no data migration or new events + +#### Scenario: Manual recategorization sheds the overlay + +- **WHEN** a user manually recategorizes a transaction that a renaming rule had + categorized +- **THEN** the transaction's displayed name reverts to its raw + payee/description, consistent with the rule no longer being its provenance + +### Requirement: Rule management page + +The system SHALL provide a dedicated rules page, linked from the primary +navigation, replacing rule management in Settings. The page SHALL list all rules +with their pattern, match type, amount constraint, display name, target +category, and active state, and SHALL provide rule search over these fields. +Rule creation, enable, and disable SHALL be available from this page with +unchanged semantics (including the retroactive-apply offer on creation). + +#### Scenario: Rules relocated + +- **WHEN** a user opens Settings +- **THEN** rule management is no longer present, and the rules page is reachable + from the primary navigation + +#### Scenario: Rule search + +- **WHEN** a user searches the rules page for "netflix" +- **THEN** rules whose pattern, display name, or category name match are listed + +### Requirement: Rule inspection + +The system SHALL provide a per-rule detail view showing: (1) what the rule did — +transactions whose categorization events reference the rule, from the +append-only log; (2) what the rule would hit — a live, read-only probe across +all non-removed transactions of non-hidden accounts, each hit labeled with its +current categorization status (uncategorized, this rule, another rule, or +manual); and (3) match health — when the rule last fired, and for +amount-constrained rules, recent transactions that matched the text pattern but +not the amount. The probe SHALL NOT modify any transaction or event. + +#### Scenario: Historical fires + +- **WHEN** a user opens a rule's detail view +- **THEN** transactions the rule categorized are listed from the event log, + including ones later recategorized + +#### Scenario: Prospective matches labeled + +- **WHEN** a user views a rule's would-hit preview +- **THEN** matching transactions are shown with their current status, and none + are modified + +#### Scenario: Amount drift surfaced + +- **WHEN** an amount-constrained rule's text pattern matches recent transactions + at a different amount +- **THEN** the detail view surfaces those transactions as + pattern-hit/amount-miss, indicating a probable price change + +### Requirement: Rule creation from the ledger + +The system SHALL allow creating a rule from any ledger transaction via a +slide-over tray, without leaving the ledger. The tray SHALL be pre-filled from +the transaction: its payee (preferred) or description as the pattern, with the +transaction's amount available as an opt-in constraint and an optional display +name. When opened from an active ledger search, the search term SHALL be offered +as the pattern. Saving SHALL create the rule with unchanged creation semantics, +including the offer to retroactively apply it to matching uncategorized +transactions. + +#### Scenario: Create rule from a transaction + +- **WHEN** a user opens the rule tray from a ledger row and saves +- **THEN** a rule is created pre-filled from that transaction without navigating + away, and the retroactive-apply offer is presented + +#### Scenario: Amount opt-in + +- **WHEN** a user opens the rule tray from a transaction +- **THEN** the amount constraint is offered but not enabled by default + +#### Scenario: Search term becomes pattern + +- **WHEN** a user opens the rule tray while a ledger text search is active +- **THEN** the search term is pre-filled as the rule pattern diff --git a/openspec/specs/csv-import/spec.md b/openspec/specs/csv-import/spec.md index 59e5d10..eefeb05 100644 --- a/openspec/specs/csv-import/spec.md +++ b/openspec/specs/csv-import/spec.md @@ -1,21 +1,25 @@ # csv-import Specification ## Purpose -TBD - created by archiving change csv-backfill-import. Update Purpose after archive. + +TBD - created by archiving change csv-backfill-import. Update Purpose after +archive. + ## Requirements + ### Requirement: Import attribution to an existing account -The system SHALL require every CSV import to be attributed to exactly one account -that already exists in the database. The system SHALL NOT create, rename, or -otherwise modify an account as a result of an import, preserving the -`account-management` rule that accounts originate only from sync data. Accounts in -state `HIDDEN` SHALL NOT be offered as import targets. +The system SHALL require every CSV import to be attributed to exactly one +account that already exists in the database. The system SHALL NOT create, +rename, or otherwise modify an account as a result of an import, preserving the +`account-management` rule that accounts originate only from sync data. Accounts +in state `HIDDEN` SHALL NOT be offered as import targets. #### Scenario: User selects a target account - **WHEN** a user begins a CSV import -- **THEN** the system requires them to choose one existing non-hidden account, and - every transaction ingested from that file is attached to that account +- **THEN** the system requires them to choose one existing non-hidden account, + and every transaction ingested from that file is attached to that account #### Scenario: No account selected @@ -26,15 +30,16 @@ state `HIDDEN` SHALL NOT be offered as import targets. The system SHALL store the uploaded file's bytes verbatim in an `imports` record before any parsing, mapping, or normalization occurs. The archived payload SHALL -never be mutated or deleted by the application. The system SHALL store the chosen -column mapping and the duplicate decisions on the same record, so that the -outcome of an import is a deterministic function of the archived record alone. +never be mutated or deleted by the application. The system SHALL store the +chosen column mapping and the duplicate decisions on the same record, so that +the outcome of an import is a deterministic function of the archived record +alone. #### Scenario: File archived on upload - **WHEN** a user uploads a CSV file -- **THEN** an `imports` row is committed containing the exact uploaded bytes with - status `draft`, before any row is parsed +- **THEN** an `imports` row is committed containing the exact uploaded bytes + with status `draft`, before any row is parsed #### Scenario: Import is replayable @@ -45,29 +50,29 @@ outcome of an import is a deterministic function of the archived record alone. #### Scenario: Unparseable file - **WHEN** an uploaded file cannot be parsed as CSV -- **THEN** the archived record is retained, the failure is stated plainly with the - reason, and no transactions are ingested +- **THEN** the archived record is retained, the failure is stated plainly with + the reason, and no transactions are ingested ### Requirement: Column mapping The system SHALL detect a CSV's date, amount, and description columns from its header row where possible, and SHALL require the user to confirm or correct the mapping before commit. The system SHALL support amounts expressed as a single -signed column and as separate debit and credit columns. The system SHALL treat the -interpretation of ambiguous numeric dates (whether `03/04/2026` is March 4th or -April 3rd) as a user-confirmable part of the mapping, inferring it from the data -where the data settles it and defaulting to month-first otherwise. The system -SHALL accept the negative conventions common to bank exports, including +signed column and as separate debit and credit columns. The system SHALL treat +the interpretation of ambiguous numeric dates (whether `03/04/2026` is March 4th +or April 3rd) as a user-confirmable part of the mapping, inferring it from the +data where the data settles it and defaulting to month-first otherwise. The +system SHALL accept the negative conventions common to bank exports, including parenthesized values and trailing minus signs, and SHALL convert amounts to -integer cents without floating-point arithmetic. The system SHALL map the file's date to the -transaction's posted timestamp and record imported transactions as not pending; -when the file exposes a distinct transaction date, the system SHALL map it to the -transaction date. +integer cents without floating-point arithmetic. The system SHALL map the file's +date to the transaction's posted timestamp and record imported transactions as +not pending; when the file exposes a distinct transaction date, the system SHALL +map it to the transaction date. #### Scenario: Headers auto-detected -- **WHEN** a CSV's header row contains recognizable date, amount, and description - columns +- **WHEN** a CSV's header row contains recognizable date, amount, and + description columns - **THEN** the system pre-selects them and presents the mapping for confirmation #### Scenario: User corrects a wrong guess @@ -95,8 +100,8 @@ transaction date. #### Scenario: Separate debit and credit columns - **WHEN** a file expresses amounts as separate debit and credit columns -- **THEN** the user can map both, and each row resolves to a single signed integer - cent amount +- **THEN** the user can map both, and each row resolves to a single signed + integer cent amount #### Scenario: Imported rows appear in reports @@ -107,8 +112,8 @@ transaction date. ### Requirement: Stable synthetic identity The system SHALL derive a stable, deterministic identifier for each imported -transaction from its content — the target account, date, amount, and description — -combined with an occurrence index distinguishing rows that are otherwise +transaction from its content — the target account, date, amount, and description +— combined with an occurrence index distinguishing rows that are otherwise identical within the same file. Identifiers SHALL be namespaced so they are distinguishable from provider-supplied identifiers. Importing a file whose rows have already been ingested under the same identifiers SHALL NOT create duplicate @@ -122,10 +127,10 @@ rows. #### Scenario: Overlapping files -- **WHEN** a user imports a January–March file and then a February–April file into - the same account -- **THEN** the February–March rows are recognized as already ingested and only the - April rows are added +- **WHEN** a user imports a January–March file and then a February–April file + into the same account +- **THEN** the February–March rows are recognized as already ingested and only + the April rows are added #### Scenario: Genuinely identical transactions @@ -159,8 +164,8 @@ SHALL be able to override any flagged row to be imported. #### Scenario: User keeps a false positive -- **WHEN** a flagged row is in fact a distinct transaction and the user marks it to - be imported +- **WHEN** a flagged row is in fact a distinct transaction and the user marks it + to be imported - **THEN** it is ingested as a new transaction alongside the existing one #### Scenario: Preview before commit @@ -172,20 +177,21 @@ SHALL be able to override any flagged row to be imported. ### Requirement: Additive-only ingestion -An import SHALL only insert transactions. The system SHALL NOT, as a result of an -import, modify or remove any existing transaction, reconcile pending transactions, -change any account's state, or write balance snapshots. Reconciliation and -feed-authority behavior SHALL remain exclusive to sync. +An import SHALL only insert transactions. The system SHALL NOT, as a result of +an import, modify or remove any existing transaction, reconcile pending +transactions, change any account's state, or write balance snapshots. +Reconciliation and feed-authority behavior SHALL remain exclusive to sync. #### Scenario: Pending transactions untouched -- **WHEN** an import is committed into an account that holds pending transactions - absent from the CSV +- **WHEN** an import is committed into an account that holds pending + transactions absent from the CSV - **THEN** those pending transactions remain unchanged and are not removed #### Scenario: Existing transaction not overwritten -- **WHEN** a candidate row resolves to an identifier already present in the account +- **WHEN** a candidate row resolves to an identifier already present in the + account - **THEN** the existing transaction is left exactly as it was #### Scenario: No balance snapshots @@ -200,8 +206,8 @@ feed-authority behavior SHALL remain exclusive to sync. ### Requirement: Rule application after import -The system SHALL apply existing categorization rules to imported transactions once -the import is committed, using the same rule precedence and the same `rule` +The system SHALL apply existing categorization rules to imported transactions +once the import is committed, using the same rule precedence and the same `rule` categorization event source as sync. Imported transactions SHALL NOT introduce a new categorization event source. @@ -222,23 +228,24 @@ new categorization event source. The system SHALL record every import and surface the record in Settings, showing the file name, target account, parsed date range, counts of rows imported and -skipped, and the commit time. Each imported transaction SHALL record which import -produced it. The system SHALL allow a committed import to be undone as a unit, -removing the transactions it produced without deleting any categorization event -history. An undone import SHALL be re-importable after correcting its mapping. +skipped, and the commit time. Each imported transaction SHALL record which +import produced it. The system SHALL allow a committed import to be undone as a +unit, removing the transactions it produced without deleting any categorization +event history. An undone import SHALL be re-importable after correcting its +mapping. #### Scenario: Import listed in settings - **WHEN** a user opens the import record in Settings -- **THEN** each import is listed with its file name, account, date range, counts, - and commit time +- **THEN** each import is listed with its file name, account, date range, + counts, and commit time #### Scenario: Undo removes only that import's rows - **WHEN** a user undoes an import - **THEN** exactly the transactions that import produced are removed from the - ledger and reports, no synced transaction is affected, and the import is marked - undone + ledger and reports, no synced transaction is affected, and the import is + marked undone #### Scenario: Event history survives undo @@ -250,6 +257,5 @@ history. An undone import SHALL be re-importable after correcting its mapping. - **WHEN** a user undoes an import made with a wrong mapping and re-imports the same file with a corrected mapping -- **THEN** the corrected transactions are ingested and the undone import's rows do - not reappear - +- **THEN** the corrected transactions are ingested and the undone import's rows + do not reappear diff --git a/openspec/specs/reporting/spec.md b/openspec/specs/reporting/spec.md index a1601f6..0c8e9cf 100644 --- a/openspec/specs/reporting/spec.md +++ b/openspec/specs/reporting/spec.md @@ -1,81 +1,151 @@ # reporting Specification ## Purpose -TBD - created by archiving change bootstrap-finance-app. Update Purpose after archive. + +TBD - created by archiving change bootstrap-finance-app. Update Purpose after +archive. + ## Requirements + ### Requirement: Monthly income and expense by category -The system SHALL display, for a selected month, total income and total expenses broken down by category, computed from posted transactions of non-hidden accounts. Transactions in transfer-kind categories SHALL be excluded from all totals. Uncategorized transactions SHALL be shown as their own line with a count, so gaps in categorization are visible rather than silently distorting totals. + +The system SHALL display, for a selected month, total income and total expenses +broken down by category, computed from posted transactions of non-hidden +accounts. Transactions in transfer-kind categories SHALL be excluded from all +totals. Uncategorized transactions SHALL be shown as their own line with a +count, so gaps in categorization are visible rather than silently distorting +totals. #### Scenario: Category totals for a month + - **WHEN** a user views the report for a month -- **THEN** each category shows its total for that month (integer-cent arithmetic), grouped into income and expense sections, with an overall income, expense, and net figure +- **THEN** each category shows its total for that month (integer-cent + arithmetic), grouped into income and expense sections, with an overall income, + expense, and net figure #### Scenario: Transfers excluded -- **WHEN** a credit-card payment produced equal-and-opposite transactions categorized as Transfer + +- **WHEN** a credit-card payment produced equal-and-opposite transactions + categorized as Transfer - **THEN** neither side appears in income or expense totals #### Scenario: Uncategorized surfaced + - **WHEN** the selected month contains uncategorized transactions -- **THEN** the report shows an "Uncategorized" line with their total and count, linking to the ledger filtered to them +- **THEN** the report shows an "Uncategorized" line with their total and count, + linking to the ledger filtered to them ### Requirement: Net worth over time -The system SHALL display net worth over time computed from balance snapshots: for each day with data, the sum of every non-hidden account's most recent snapshot on or before that day, with assets (positive balances) and liabilities (negative balances) distinguishable. History SHALL extend back to the earliest snapshot. + +The system SHALL display net worth over time computed from balance snapshots: +for each day with data, the sum of every non-hidden account's most recent +snapshot on or before that day, with assets (positive balances) and liabilities +(negative balances) distinguishable. History SHALL extend back to the earliest +snapshot. #### Scenario: Net worth chart + - **WHEN** a user views the net worth report -- **THEN** a time series shows total net worth per day derived from latest-snapshot-per-account, including asset/liability breakdown +- **THEN** a time series shows total net worth per day derived from + latest-snapshot-per-account, including asset/liability breakdown #### Scenario: Hidden accounts excluded + - **WHEN** an account is marked `HIDDEN` - **THEN** its balances are excluded from the net worth series ### Requirement: Transaction ledger -The system SHALL provide a ledger view of transactions filterable by account, category (including uncategorized), month, pending status, and source (synced vs. imported), and searchable by free text. Text search SHALL match case-insensitively against the raw description, payee, and memo fields and against the effective displayed name produced by rule display-name overlays, so search finds what the user sees. Search SHALL compose with all structured filters, and the source filter SHALL compose with all other filters. The ledger shows date, account, displayed name (rule overlay applied when present), amount, category, and a provenance indicator (rule vs. person). The ledger SHALL NOT mark a transaction's source on the row itself; a transaction's origin — synced from a connection, or imported from a named file — SHALL be shown in its history panel. The ledger is the surface for manual categorization and for creating rules from transactions. + +The system SHALL provide a ledger view of transactions filterable by account, +category (including uncategorized), month, pending status, and source (synced +vs. imported), and searchable by free text. Text search SHALL match +case-insensitively against the raw description, payee, and memo fields and +against the effective displayed name produced by rule display-name overlays, so +search finds what the user sees. Search SHALL compose with all structured +filters, and the source filter SHALL compose with all other filters. The ledger +shows date, account, displayed name (rule overlay applied when present), amount, +category, and a provenance indicator (rule vs. person). The ledger SHALL NOT +mark a transaction's source on the row itself; a transaction's origin — synced +from a connection, or imported from a named file — SHALL be shown in its history +panel. The ledger is the surface for manual categorization and for creating +rules from transactions. #### Scenario: Filter to uncategorized + - **WHEN** a user filters the ledger to uncategorized transactions -- **THEN** only transactions with no current category are listed, ready for manual assignment +- **THEN** only transactions with no current category are listed, ready for + manual assignment #### Scenario: Provenance indicator + - **WHEN** a categorized transaction is displayed -- **THEN** the row indicates whether the category came from a rule, a person (with their identity), or reconciliation carry-forward +- **THEN** the row indicates whether the category came from a rule, a person + (with their identity), or reconciliation carry-forward #### Scenario: Search matches a renamed transaction -- **WHEN** a rule renames "ACH TRANSFER 4417" to display as "Rent" and a user searches the ledger for "rent" + +- **WHEN** a rule renames "ACH TRANSFER 4417" to display as "Rent" and a user + searches the ledger for "rent" - **THEN** the transaction is found, even though no raw field contains "rent" #### Scenario: Search composes with filters + - **WHEN** a user searches for "netflix" with a month filter active -- **THEN** only that month's transactions matching the text (raw fields or displayed name) are listed +- **THEN** only that month's transactions matching the text (raw fields or + displayed name) are listed #### Scenario: Filter to imported transactions + - **WHEN** a user filters the ledger by source to imported transactions - **THEN** only transactions produced by a CSV import are listed #### Scenario: Source filter composes -- **WHEN** a user filters by source to imported with an account and month filter active + +- **WHEN** a user filters by source to imported with an account and month filter + active - **THEN** only that account's imported transactions in that month are listed #### Scenario: Origin shown in history -- **WHEN** a user opens the history panel of a transaction that came from a CSV import -- **THEN** the panel states that it was imported and names the file and import date, distinguishing it from a transaction synced from a connection + +- **WHEN** a user opens the history panel of a transaction that came from a CSV + import +- **THEN** the panel states that it was imported and names the file and import + date, distinguishing it from a transaction synced from a connection ### Requirement: Dashboard month-to-date overview -The system SHALL display on the dashboard, scoped to the current calendar month and to non-hidden accounts: (1) a spending pie chart of expense totals by category computed from posted transactions with the same semantics as the monthly report (transfer-kind categories excluded, uncategorized shown as its own slice); (2) total income and total expenses from posted transactions; (3) the count and total amount of pending transactions whose effective date falls in the current month; and (4) a small fixed number of the most recent transactions, each linking to the transaction ledger. Each section SHALL render a clear empty state when the month has no qualifying data. + +The system SHALL display on the dashboard, scoped to the current calendar month +and to non-hidden accounts: (1) a spending pie chart of expense totals by +category computed from posted transactions with the same semantics as the +monthly report (transfer-kind categories excluded, uncategorized shown as its +own slice); (2) total income and total expenses from posted transactions; (3) +the count and total amount of pending transactions whose effective date falls in +the current month; and (4) a small fixed number of the most recent transactions, +each linking to the transaction ledger. Each section SHALL render a clear empty +state when the month has no qualifying data. #### Scenario: Current month at a glance + - **WHEN** a user views the dashboard during a month with posted transactions -- **THEN** the spend pie reflects that month's expense totals by category, and stat tiles show the month's total income and total expenses in integer-cent arithmetic +- **THEN** the spend pie reflects that month's expense totals by category, and + stat tiles show the month's total income and total expenses in integer-cent + arithmetic #### Scenario: Pending stats + - **WHEN** the current month contains pending transactions -- **THEN** the dashboard shows their count and summed amount, distinct from posted income/expense totals +- **THEN** the dashboard shows their count and summed amount, distinct from + posted income/expense totals #### Scenario: Recent transactions + - **WHEN** a user views the dashboard -- **THEN** the most recent transactions (pending first, then by effective date descending) are listed with date, account, displayed name, amount, and a link to the full ledger +- **THEN** the most recent transactions (pending first, then by effective date + descending) are listed with date, account, displayed name, amount, and a link + to the full ledger #### Scenario: Empty month -- **WHEN** the current month has no transactions -- **THEN** the overview sections show empty states rather than being hidden or erroring, and account balances remain visible +- **WHEN** the current month has no transactions +- **THEN** the overview sections show empty states rather than being hidden or + erroring, and account balances remain visible diff --git a/openspec/specs/simplefin-sync/spec.md b/openspec/specs/simplefin-sync/spec.md index f2e5787..c5a6680 100644 --- a/openspec/specs/simplefin-sync/spec.md +++ b/openspec/specs/simplefin-sync/spec.md @@ -1,72 +1,131 @@ # simplefin-sync Specification ## Purpose -TBD - created by archiving change bootstrap-finance-app. Update Purpose after archive. + +TBD - created by archiving change bootstrap-finance-app. Update Purpose after +archive. + ## Requirements + ### Requirement: Connection bootstrap via setup token -The system SHALL provide a settings flow where an authenticated user pastes a one-time SimpleFIN setup token. The system SHALL decode the token, POST to the claim URL, and store the resulting Access URL in the `connections` table. The Access URL SHALL be stored only in the database (never in environment variables or logs). The data model SHALL support multiple connections even though one is expected initially. + +The system SHALL provide a settings flow where an authenticated user pastes a +one-time SimpleFIN setup token. The system SHALL decode the token, POST to the +claim URL, and store the resulting Access URL in the `connections` table. The +Access URL SHALL be stored only in the database (never in environment variables +or logs). The data model SHALL support multiple connections even though one is +expected initially. #### Scenario: Valid setup token claimed + - **WHEN** a user submits a valid, unused setup token -- **THEN** the system claims it, stores a connection row with the Access URL and claim timestamp, and triggers an initial sync +- **THEN** the system claims it, stores a connection row with the Access URL and + claim timestamp, and triggers an initial sync #### Scenario: Already-used setup token + - **WHEN** the claim request returns 403 -- **THEN** the system shows a message that the token was already claimed and a fresh one must be generated at SimpleFIN Bridge +- **THEN** the system shows a message that the token was already claimed and a + fresh one must be generated at SimpleFIN Bridge - **AND** no connection row is created ### Requirement: Scheduled and manual sync -The system SHALL sync each connection once daily via a scheduled job and SHALL provide a manual "sync now" action in the UI. A sync fetches `GET {access_url}/accounts` including pending transactions and a start date that safely overlaps previously fetched data. + +The system SHALL sync each connection once daily via a scheduled job and SHALL +provide a manual "sync now" action in the UI. A sync fetches +`GET {access_url}/accounts` including pending transactions and a start date that +safely overlaps previously fetched data. #### Scenario: Daily scheduled sync + - **WHEN** the daily schedule fires -- **THEN** the system performs a sync for every connection and records the outcome +- **THEN** the system performs a sync for every connection and records the + outcome #### Scenario: Manual sync + - **WHEN** an authenticated user triggers "sync now" - **THEN** a sync runs immediately and the UI reflects the result #### Scenario: Sync failure + - **WHEN** the SimpleFIN request fails (network error or non-2xx) -- **THEN** the system records a failed sync with the error detail and leaves all previously normalized data untouched +- **THEN** the system records a failed sync with the error detail and leaves all + previously normalized data untouched ### Requirement: Raw response archival -The system SHALL store the verbatim response body of every sync attempt in a `raw_syncs` table (fetch timestamp, payload, success flag, error detail) before any normalization occurs. Raw payloads SHALL never be mutated or deleted by the application. + +The system SHALL store the verbatim response body of every sync attempt in a +`raw_syncs` table (fetch timestamp, payload, success flag, error detail) before +any normalization occurs. Raw payloads SHALL never be mutated or deleted by the +application. #### Scenario: Successful sync archived + - **WHEN** a sync response is received -- **THEN** a `raw_syncs` row with the exact response body is committed before normalization begins +- **THEN** a `raw_syncs` row with the exact response body is committed before + normalization begins #### Scenario: Normalization can be replayed + - **WHEN** normalization logic is re-run over an archived payload -- **THEN** it produces the same normalized state as the original run (idempotent, pure function of the payload) +- **THEN** it produces the same normalized state as the original run + (idempotent, pure function of the payload) ### Requirement: Idempotent normalization -The system SHALL normalize archived payloads into `accounts`, `transactions`, and `balance_snapshots` via upserts keyed on SimpleFIN identifiers. Monetary amounts SHALL be converted from SimpleFIN's numeric strings to integer cents exactly once at normalization. Each transaction row SHALL retain the verbatim SimpleFIN `extra` payload in a JSON column. Running normalization twice over the same payload SHALL produce no duplicate rows. + +The system SHALL normalize archived payloads into `accounts`, `transactions`, +and `balance_snapshots` via upserts keyed on SimpleFIN identifiers. Monetary +amounts SHALL be converted from SimpleFIN's numeric strings to integer cents +exactly once at normalization. Each transaction row SHALL retain the verbatim +SimpleFIN `extra` payload in a JSON column. Running normalization twice over the +same payload SHALL produce no duplicate rows. #### Scenario: New transaction ingested + - **WHEN** a payload contains a transaction id not yet in the database -- **THEN** a transaction row is inserted with amount as integer cents, raw description, provider-supplied payee and memo when present, timestamps, pending flag, and verbatim extra JSON +- **THEN** a transaction row is inserted with amount as integer cents, raw + description, provider-supplied payee and memo when present, timestamps, + pending flag, and verbatim extra JSON #### Scenario: Repeated payload is a no-op + - **WHEN** the same payload is normalized a second time - **THEN** row counts and contents are unchanged ### Requirement: Balance snapshots -The system SHALL record one balance snapshot per account per successful sync fetch (a sync may perform an extra deep-backfill fetch when it discovers a new account), capturing balance and available balance (integer cents) with the capture timestamp, to power net-worth-over-time reporting. Snapshots SHALL never be deleted by the application. + +The system SHALL record one balance snapshot per account per successful sync +fetch (a sync may perform an extra deep-backfill fetch when it discovers a new +account), capturing balance and available balance (integer cents) with the +capture timestamp, to power net-worth-over-time reporting. Snapshots SHALL never +be deleted by the application. #### Scenario: Snapshot captured on sync + - **WHEN** a successful sync fetch reports an account balance -- **THEN** a snapshot row is inserted for that account with the reported balance and timestamp +- **THEN** a snapshot row is inserted for that account with the reported balance + and timestamp ### Requirement: Pending-to-posted reconciliation -The system SHALL reconcile pending transactions when they post. A newly posted transaction SHALL be matched to an existing pending row by same account, identical amount, and date proximity within a small window. On match, the pending row is replaced by the posted transaction and any existing categorization is carried forward via a categorization event with source `reconciliation`. Pending rows absent from the feed and unmatched by any posted transaction SHALL be removed. + +The system SHALL reconcile pending transactions when they post. A newly posted +transaction SHALL be matched to an existing pending row by same account, +identical amount, and date proximity within a small window. On match, the +pending row is replaced by the posted transaction and any existing +categorization is carried forward via a categorization event with source +`reconciliation`. Pending rows absent from the feed and unmatched by any posted +transaction SHALL be removed. #### Scenario: Categorized pending transaction posts under a new id + - **WHEN** a posted transaction matches a pending row that has a category -- **THEN** the posted transaction replaces the pending row, receives the same category, and a `reconciliation` categorization event records the carry-forward +- **THEN** the posted transaction replaces the pending row, receives the same + category, and a `reconciliation` categorization event records the + carry-forward #### Scenario: Stale pending transaction disappears -- **WHEN** a pending row no longer appears in the feed and no posted transaction matches it -- **THEN** the pending row is removed +- **WHEN** a pending row no longer appears in the feed and no posted transaction + matches it +- **THEN** the pending row is removed diff --git a/scripts/build-image.ts b/scripts/build-image.ts index a110066..81064b0 100644 --- a/scripts/build-image.ts +++ b/scripts/build-image.ts @@ -9,25 +9,25 @@ import { currentVersion } from "./version.ts"; const IMAGE = "atcr.io/graham.systems/quantum"; async function tryGit(...args: string[]): Promise { - try { - const { code, stdout } = await new Deno.Command("git", { - args, - stdout: "piped", - stderr: "null", - }).output(); - return code === 0 ? new TextDecoder().decode(stdout).trim() : ""; - } catch { - return ""; - } + try { + const { code, stdout } = await new Deno.Command("git", { + args, + stdout: "piped", + stderr: "null", + }).output(); + return code === 0 ? new TextDecoder().decode(stdout).trim() : ""; + } catch { + return ""; + } } async function run(cmd: string, args: string[]): Promise { - const { code } = await new Deno.Command(cmd, { - args, - stdout: "inherit", - stderr: "inherit", - }).output(); - return code; + const { code } = await new Deno.Command(cmd, { + args, + stdout: "inherit", + stderr: "inherit", + }).output(); + return code; } const version = await currentVersion(); @@ -41,28 +41,32 @@ const extra = passthroughAt === -1 ? [] : Deno.args.slice(passthroughAt + 1); const tags = [`${IMAGE}:${version}`, `${IMAGE}:latest`]; -console.log(`Building ${IMAGE}:${version} (revision ${revision || "unknown"}, created ${created})`); +console.log( + `Building ${IMAGE}:${version} (revision ${ + revision || "unknown" + }, created ${created})`, +); const buildArgs = [ - "build", - "--build-arg", - `VERSION=${version}`, - "--build-arg", - `CREATED=${created}`, - "--build-arg", - `REVISION=${revision}`, - ...tags.flatMap((t) => ["-t", t]), - ...extra, - ".", + "build", + "--build-arg", + `VERSION=${version}`, + "--build-arg", + `CREATED=${created}`, + "--build-arg", + `REVISION=${revision}`, + ...tags.flatMap((t) => ["-t", t]), + ...extra, + ".", ]; let code = await run("docker", buildArgs); if (code === 0 && push) { - for (const t of tags) { - console.log(`Pushing ${t}`); - code = await run("docker", ["push", t]); - if (code !== 0) break; - } + for (const t of tags) { + console.log(`Pushing ${t}`); + code = await run("docker", ["push", t]); + if (code !== 0) break; + } } Deno.exit(code); diff --git a/scripts/changelog.ts b/scripts/changelog.ts index 275496c..f7e7a71 100644 --- a/scripts/changelog.ts +++ b/scripts/changelog.ts @@ -10,15 +10,17 @@ import { nextVersion } from "./version.ts"; async function git(args: string[]): Promise { - const { code, stdout, stderr } = await new Deno.Command("git", { - args, - stdout: "piped", - stderr: "piped", - }).output(); - if (code !== 0) { - throw new Error(`git ${args.join(" ")}: ${new TextDecoder().decode(stderr)}`); - } - return new TextDecoder().decode(stdout).trim(); + const { code, stdout, stderr } = await new Deno.Command("git", { + args, + stdout: "piped", + stderr: "piped", + }).output(); + if (code !== 0) { + throw new Error( + `git ${args.join(" ")}: ${new TextDecoder().decode(stderr)}`, + ); + } + return new TextDecoder().decode(stdout).trim(); } /** @@ -27,7 +29,15 @@ async function git(args: string[]): Promise { * commits whose diff adds or removes a line containing `"version"`. */ export async function lastReleaseCommit(): Promise { - return await git(["log", "-1", "--format=%H", "-G", '"version"', "--", "package.json"]); + return await git([ + "log", + "-1", + "--format=%H", + "-G", + '"version"', + "--", + "package.json", + ]); } // Skip the release commits themselves (version/changelog bumps) so they don't @@ -36,52 +46,58 @@ const RELEASE_SUBJECT = /^(release\b|\d{4}\.\d{2}\.\d{2}\b)/i; /** Commit subjects for the pending release, oldest first. */ export async function pendingCommits(): Promise { - const since = await lastReleaseCommit(); - const range = since ? [`${since}..HEAD`] : ["HEAD"]; - const out = await git(["log", ...range, "--reverse", "--no-merges", "--format=%s"]); - if (!out) return []; - return out - .split("\n") - .map((s) => s.trim()) - .filter((s) => s && !RELEASE_SUBJECT.test(s)); + const since = await lastReleaseCommit(); + const range = since ? [`${since}..HEAD`] : ["HEAD"]; + const out = await git([ + "log", + ...range, + "--reverse", + "--no-merges", + "--format=%s", + ]); + if (!out) return []; + return out + .split("\n") + .map((s) => s.trim()) + .filter((s) => s && !RELEASE_SUBJECT.test(s)); } interface Entry { - area: string; - summary: string; + area: string; + summary: string; } function parse(subject: string): Entry { - const m = subject.match(/^([a-z][a-z0-9 +/_-]*?):\s+(.*)$/i); - if (m) return { area: m[1].trim(), summary: m[2].trim() }; - return { area: "other", summary: subject }; + const m = subject.match(/^([a-z][a-z0-9 +/_-]*?):\s+(.*)$/i); + if (m) return { area: m[1].trim(), summary: m[2].trim() }; + return { area: "other", summary: subject }; } /** Render a Keep-a-Changelog section, grouping entries by their area prefix. */ export function renderSection(version: string, subjects: string[]): string { - const groups = new Map(); - for (const s of subjects) { - const { area, summary } = parse(s); - const bucket = groups.get(area) ?? []; - bucket.push(summary); - groups.set(area, bucket); - } + const groups = new Map(); + for (const s of subjects) { + const { area, summary } = parse(s); + const bucket = groups.get(area) ?? []; + bucket.push(summary); + groups.set(area, bucket); + } - const lines = [`## ${version}`, ""]; - if (groups.size === 0) { - lines.push("_No user-facing changes._", ""); - return lines.join("\n"); - } - for (const [area, summaries] of groups) { - lines.push(`### ${area}`, ""); - for (const s of summaries) lines.push(`- ${s}`); - lines.push(""); - } - return lines.join("\n"); + const lines = [`## ${version}`, ""]; + if (groups.size === 0) { + lines.push("_No user-facing changes._", ""); + return lines.join("\n"); + } + for (const [area, summaries] of groups) { + lines.push(`### ${area}`, ""); + for (const s of summaries) lines.push(`- ${s}`); + lines.push(""); + } + return lines.join("\n"); } if (import.meta.main) { - const version = await nextVersion(); - const subjects = await pendingCommits(); - console.log(renderSection(version, subjects)); + const version = await nextVersion(); + const subjects = await pendingCommits(); + console.log(renderSection(version, subjects)); } diff --git a/scripts/diagnose-sync.ts b/scripts/diagnose-sync.ts index ac736f5..489bf27 100644 --- a/scripts/diagnose-sync.ts +++ b/scripts/diagnose-sync.ts @@ -11,40 +11,50 @@ // podman cp :/data/quantum.db ./quantum.db // deno run -A scripts/diagnose-sync.ts ./quantum.db -import { DatabaseSync } from 'node:sqlite'; +import { DatabaseSync } from "node:sqlite"; const args = [...Deno.args]; -const dumpIndex = args.indexOf('--dump'); -const dumpFilter = dumpIndex >= 0 ? (args.splice(dumpIndex, 2)[1] ?? '').toLowerCase() : null; -const path = args[0] ?? './data/quantum.db'; +const dumpIndex = args.indexOf("--dump"); +const dumpFilter = dumpIndex >= 0 + ? (args.splice(dumpIndex, 2)[1] ?? "").toLowerCase() + : null; +const path = args[0] ?? "./data/quantum.db"; const db = new DatabaseSync(path); const fmtDate = (unix: number | null) => - unix ? new Date(unix * 1000).toISOString().slice(0, 10) : '—'; + unix ? new Date(unix * 1000).toISOString().slice(0, 10) : "—"; console.log(`\n=== Sync attempts (latest 5) ===`); const syncs = db .prepare( `SELECT id, fetched_at, ok, error, LENGTH(payload) AS bytes - FROM raw_syncs ORDER BY id DESC LIMIT 5` + FROM raw_syncs ORDER BY id DESC LIMIT 5`, ) .all() as Record[]; for (const s of syncs) { console.log( - `#${s.id} ${s.fetched_at} ${s.ok ? 'ok' : 'FAILED'} ${s.bytes ?? 0} bytes${s.error ? ` error: ${s.error}` : ''}` + `#${s.id} ${s.fetched_at} ${s.ok ? "ok" : "FAILED"} ${ + s.bytes ?? 0 + } bytes${s.error ? ` error: ${s.error}` : ""}`, ); } const latest = db - .prepare('SELECT id, fetched_at, payload FROM raw_syncs WHERE ok = 1 ORDER BY id DESC LIMIT 1') + .prepare( + "SELECT id, fetched_at, payload FROM raw_syncs WHERE ok = 1 ORDER BY id DESC LIMIT 1", + ) .get() as { id: number; fetched_at: string; payload: string } | undefined; if (!latest?.payload) { - console.log('\nNo successful sync payload archived yet — nothing to compare.'); + console.log( + "\nNo successful sync payload archived yet — nothing to compare.", + ); Deno.exit(0); } -console.log(`\n=== Latest successful payload (raw_syncs #${latest.id}, ${latest.fetched_at}) ===`); +console.log( + `\n=== Latest successful payload (raw_syncs #${latest.id}, ${latest.fetched_at}) ===`, +); const parsed = JSON.parse(latest.payload) as { errors?: unknown[]; accounts?: Record[]; @@ -54,43 +64,47 @@ if (parsed.errors?.length) { console.log(`Connection errors reported by the Bridge:`); for (const e of parsed.errors) console.log(` ! ${e}`); } else { - console.log('No connection errors in payload.'); + console.log("No connection errors in payload."); } -console.log('\nWhat the Bridge sent, per account:'); +console.log("\nWhat the Bridge sent, per account:"); for (const account of parsed.accounts ?? []) { const txns = (account.transactions ?? []) as Record[]; const posted = txns - .map((t) => (typeof t.posted === 'number' ? t.posted : null)) + .map((t) => (typeof t.posted === "number" ? t.posted : null)) .filter((p): p is number => p != null && p > 0); const range = posted.length ? `${fmtDate(Math.min(...posted))} … ${fmtDate(Math.max(...posted))}` - : 'no posted dates'; + : "no posted dates"; console.log( ` ${String(account.id).padEnd(28)} ${String(account.name).padEnd(24)} ` + - `${String(txns.length).padStart(4)} txns in payload (${range})` + `${String(txns.length).padStart(4)} txns in payload (${range})`, ); } -console.log('\nWhat Quantum has normalized, per account:'); +console.log("\nWhat Quantum has normalized, per account:"); const rows = db .prepare( `SELECT a.id, COALESCE(a.display_name, a.name) AS name, a.state, COUNT(t.id) AS n, MIN(t.posted) AS min_posted, MAX(t.posted) AS max_posted FROM accounts a LEFT JOIN transactions t ON t.account_id = a.id AND t.removed_at IS NULL - GROUP BY a.id ORDER BY a.id` + GROUP BY a.id ORDER BY a.id`, ) .all() as Record[]; for (const r of rows) { console.log( ` ${String(r.id).padEnd(28)} ${String(r.name).padEnd(24)} ` + `${String(r.n).padStart(4)} txns in db ` + - `(${fmtDate(r.min_posted as number | null)} … ${fmtDate(r.max_posted as number | null)}) [${r.state}]` + `(${fmtDate(r.min_posted as number | null)} … ${ + fmtDate(r.max_posted as number | null) + }) [${r.state}]`, ); } if (dumpFilter) { - console.log(`\n=== Verbatim transactions for accounts matching "${dumpFilter}" ===`); + console.log( + `\n=== Verbatim transactions for accounts matching "${dumpFilter}" ===`, + ); for (const account of parsed.accounts ?? []) { const label = `${account.id} ${account.name}`.toLowerCase(); if (!label.includes(dumpFilter)) continue; diff --git a/scripts/generate-category-palette.ts b/scripts/generate-category-palette.ts index 9370272..f330980 100644 --- a/scripts/generate-category-palette.ts +++ b/scripts/generate-category-palette.ts @@ -11,7 +11,7 @@ // // Usage: deno run -A scripts/generate-category-palette.ts -import { Poline } from 'poline'; +import { Poline } from "poline"; const COUNT = 12; const L_MIN = 0.54; @@ -27,8 +27,8 @@ const poline = new Poline({ anchorColors: [ [178, 0.62, 0.42], // spruce [42, 0.72, 0.5], // amber - [292, 0.45, 0.55] // plum - ] + [292, 0.45, 0.55], // plum + ], }); // --- OKLab <-> sRGB (Björn Ottosson's reference math) --- @@ -43,16 +43,26 @@ function linearToSrgb(c: number): number { function rgbToOklch(r: number, g: number, b: number): [number, number, number] { const [lr, lg, lb] = [srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)]; - const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); - const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); - const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); + const l = Math.cbrt( + 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb, + ); + const m = Math.cbrt( + 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb, + ); + const s = Math.cbrt( + 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb, + ); const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; return [L, Math.hypot(a, bb), (Math.atan2(bb, a) * 180) / Math.PI]; } -function oklchToRgb(L: number, C: number, hDeg: number): [number, number, number] | null { +function oklchToRgb( + L: number, + C: number, + hDeg: number, +): [number, number, number] | null { const h = (hDeg * Math.PI) / 180; const a = C * Math.cos(h); const b = C * Math.sin(h); @@ -64,27 +74,35 @@ function oklchToRgb(L: number, C: number, hDeg: number): [number, number, number const lb = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s; const rgb = [lr, lg, lb].map(linearToSrgb); if (rgb.some((c) => c < -0.001 || c > 1.001)) return null; - return rgb.map((c) => Math.min(1, Math.max(0, c))) as [number, number, number]; + return rgb.map((c) => Math.min(1, Math.max(0, c))) as [ + number, + number, + number, + ]; } function hslToRgb(h: number, s: number, l: number): [number, number, number] { const k = (n: number) => (n + h / 30) % 12; const a = s * Math.min(l, 1 - l); - const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); + const f = (n: number) => + l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); return [f(0), f(8), f(4)]; } function toHex(rgb: [number, number, number]): string { - return '#' + rgb.map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join(''); + return "#" + + rgb.map((c) => Math.round(c * 255).toString(16).padStart(2, "0")).join(""); } // --- normalize each Poline stop into the validated band --- // All candidate OKLCH hues from the Poline journey. -const candidateHues = (poline.colors as [number, number, number][]).map(([h, s, l]) => { - const [, , hue] = rgbToOklch(...hslToRgb(h, s, l)); - return (hue + 360) % 360; -}); +const candidateHues = (poline.colors as [number, number, number][]).map( + ([h, s, l]) => { + const [, , hue] = rgbToOklch(...hslToRgb(h, s, l)); + return (hue + 360) % 360; + }, +); // Greedy selection of COUNT hues maximizing minimum pairwise hue distance, // seeded from the first stop (the spruce family). @@ -110,8 +128,9 @@ while (selected.length < COUNT) { // apart in hue — that adjacency is what the CVD check exercises. selected.sort((a, b) => a - b); const half = Math.ceil(COUNT / 2); -const ordered = Array.from({ length: COUNT }, (_, i) => - i % 2 === 0 ? selected[i / 2] : selected[half + (i - 1) / 2] +const ordered = Array.from( + { length: COUNT }, + (_, i) => i % 2 === 0 ? selected[i / 2] : selected[half + (i - 1) / 2], ); const results: { oklch: string; hex: string }[] = []; @@ -123,7 +142,8 @@ for (let i = 0; i < COUNT; i++) { // Find the highest chroma (≥ floor) that stays in sRGB gamut, searching the // preferred lightness first, then the rest of the band. const lCandidates = [preferredL, 0.54, 0.56, 0.58, 0.6, 0.62, 0.52, 0.5]; - let found: { L: number; C: number; rgb: [number, number, number] } | null = null; + let found: { L: number; C: number; rgb: [number, number, number] } | null = + null; for (const L of lCandidates) { for (let C = C_MAX; C >= C_MIN - 0.001; C -= 0.005) { const rgb = oklchToRgb(L, C, hue); @@ -146,12 +166,16 @@ for (let i = 0; i < COUNT; i++) { } const final = found!; results.push({ - oklch: `oklch(${final.L.toFixed(2)} ${final.C.toFixed(3)} ${((hue + 360) % 360).toFixed(0)})`, - hex: toHex(final.rgb) + oklch: `oklch(${final.L.toFixed(2)} ${final.C.toFixed(3)} ${ + ((hue + 360) % 360).toFixed(0) + })`, + hex: toHex(final.rgb), }); } -console.log('/* CSS tokens (same palette both themes — validated for both surfaces) */'); +console.log( + "/* CSS tokens (same palette both themes — validated for both surfaces) */", +); results.forEach((r, i) => console.log(`\t--cat-${i}: ${r.oklch};`)); -console.log('\n# hex list for validate_palette.js'); -console.log(results.map((r) => r.hex).join(',')); +console.log("\n# hex list for validate_palette.js"); +console.log(results.map((r) => r.hex).join(",")); diff --git a/scripts/generate-oauth-key.ts b/scripts/generate-oauth-key.ts index 90ad367..9aab841 100644 --- a/scripts/generate-oauth-key.ts +++ b/scripts/generate-oauth-key.ts @@ -3,12 +3,16 @@ // Usage: deno task generate-key // Put the output in your environment as OAUTH_PRIVATE_KEY_JWK (single line). -const keyPair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ - 'sign', - 'verify' -]); +const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + [ + "sign", + "verify", + ], +); -const jwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); +const jwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); jwk.kid = crypto.randomUUID(); console.log(JSON.stringify(jwk)); diff --git a/scripts/release.ts b/scripts/release.ts index 1fc3a6f..86c296f 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -22,10 +22,10 @@ All notable changes to Quantum are documented here. Versions follow `; const suffix = Deno.args.includes("--break") - ? "break" - : Deno.args.includes("--feature") - ? "feature" - : ""; + ? "break" + : Deno.args.includes("--feature") + ? "feature" + : ""; const version = await nextVersion(suffix); @@ -35,11 +35,11 @@ const section = renderSection(version, await pendingCommits()); // Prepend it: keep the preamble, then the new section, then the prior sections. let prior = ""; try { - const existing = await Deno.readTextFile(CHANGELOG); - const firstHeading = existing.indexOf("## "); - prior = firstHeading === -1 ? "" : existing.slice(firstHeading); + const existing = await Deno.readTextFile(CHANGELOG); + const firstHeading = existing.indexOf("## "); + prior = firstHeading === -1 ? "" : existing.slice(firstHeading); } catch { - // No CHANGELOG.md yet — first release creates it. + // No CHANGELOG.md yet — first release creates it. } const changelog = `${PREAMBLE}\n${section}\n${prior}`.trimEnd() + "\n"; await Deno.writeTextFile(CHANGELOG, changelog); @@ -53,4 +53,6 @@ await Deno.writeTextFile(PKG, JSON.stringify(pkg, null, "\t") + "\n"); console.log(`Released ${version} (was ${previous}).`); console.log("Updated CHANGELOG.md and package.json."); console.log("Next: review/curate CHANGELOG.md, then commit both"); -console.log(` (e.g. \`jj describe -m "release ${version}"\`), then \`deno task image\`.`); +console.log( + ` (e.g. \`jj describe -m "release ${version}"\`), then \`deno task image\`.`, +); diff --git a/scripts/version.ts b/scripts/version.ts index 69c2948..1dd4d93 100644 --- a/scripts/version.ts +++ b/scripts/version.ts @@ -13,21 +13,21 @@ const PKG = new URL("../package.json", import.meta.url); const CHRONVER = /^(\d{4})\.(\d{2})\.(\d{2})(?:\.(\d+))?(-[0-9A-Za-z.-]+)?$/; export function isChronVer(v: string): boolean { - return CHRONVER.test(v); + return CHRONVER.test(v); } /** Today's date as the ChronVer date component, e.g. "2026.07.16". */ export function today(now: Date = new Date()): string { - const y = now.getFullYear(); - const m = String(now.getMonth() + 1).padStart(2, "0"); - const d = String(now.getDate()).padStart(2, "0"); - return `${y}.${m}.${d}`; + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + return `${y}.${m}.${d}`; } /** The version recorded in package.json (may be a non-ChronVer seed like 0.0.1). */ export async function pkgVersion(): Promise { - const pkg = JSON.parse(await Deno.readTextFile(PKG)); - return String(pkg.version ?? ""); + const pkg = JSON.parse(await Deno.readTextFile(PKG)); + return String(pkg.version ?? ""); } /** @@ -36,28 +36,28 @@ export async function pkgVersion(): Promise { * a sensible date-based version before its first formal release). */ export async function currentVersion(): Promise { - const v = await pkgVersion(); - return isChronVer(v) ? v : today(); + const v = await pkgVersion(); + return isChronVer(v) ? v : today(); } /** The version a release cut right now would receive. */ export async function nextVersion(suffix = ""): Promise { - const date = today(); - const current = await pkgVersion(); - let base: string; - if (current === date) { - base = `${date}.1`; - } else if (current.startsWith(`${date}.`)) { - const changeset = Number(current.slice(date.length + 1).split("-")[0]); - base = `${date}.${Number.isFinite(changeset) ? changeset + 1 : 1}`; - } else { - // A new day, or a non-ChronVer seed: start the day fresh at changeset 0. - base = date; - } - return suffix ? `${base}-${suffix}` : base; + const date = today(); + const current = await pkgVersion(); + let base: string; + if (current === date) { + base = `${date}.1`; + } else if (current.startsWith(`${date}.`)) { + const changeset = Number(current.slice(date.length + 1).split("-")[0]); + base = `${date}.${Number.isFinite(changeset) ? changeset + 1 : 1}`; + } else { + // A new day, or a non-ChronVer seed: start the day fresh at changeset 0. + base = date; + } + return suffix ? `${base}-${suffix}` : base; } if (import.meta.main) { - const wantNext = Deno.args.includes("--next"); - console.log(wantNext ? await nextVersion() : await currentVersion()); + const wantNext = Deno.args.includes("--next"); + console.log(wantNext ? await nextVersion() : await currentVersion()); } diff --git a/src/app.d.ts b/src/app.d.ts index 25eb888..1eaed63 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -15,7 +15,11 @@ declare global { // plain tsc without Deno's type library. (deno test files carry their own // /// and are excluded from svelte-check.) const Deno: { - cron(name: string, schedule: string, handler: () => void | Promise): void; + cron( + name: string, + schedule: string, + handler: () => void | Promise, + ): void; }; } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 115ebef..4e113c6 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,42 +1,47 @@ -import { building } from '$app/environment'; -import { redirect, type Handle, type ServerInit } from '@sveltejs/kit'; -import { getConfig } from '$lib/server/config'; -import { getDb, initDb } from '$lib/server/db'; -import { initOAuthClient } from '$lib/server/auth/oauth-client'; -import { deleteExpiredSessions, getSessionUser } from '$lib/server/services/sessions'; -import { runSync } from '$lib/server/services/sync'; +import { building } from "$app/environment"; +import { type Handle, redirect, type ServerInit } from "@sveltejs/kit"; +import { getConfig } from "$lib/server/config"; +import { getDb, initDb } from "$lib/server/db"; +import { initOAuthClient } from "$lib/server/auth/oauth-client"; +import { + deleteExpiredSessions, + getSessionUser, +} from "$lib/server/services/sessions"; +import { runSync } from "$lib/server/services/sync"; export const init: ServerInit = async () => { if (building) return; const config = getConfig(); // fails fast with a clear error on missing/invalid env - const db = initDb(config.dbPath, 'migrations'); + const db = initDb(config.dbPath, "migrations"); deleteExpiredSessions(db); await initOAuthClient(config, db); // Daily sync. The Bridge refreshes bank data roughly daily; more often is pointless. try { - Deno.cron('daily simplefin sync', '0 11 * * *', async () => { + Deno.cron("daily simplefin sync", "0 11 * * *", async () => { const outcomes = await runSync(getDb()); for (const o of outcomes) { console.log( - `sync connection ${o.connectionId}: ${o.ok ? 'ok' : `FAILED (${o.error})`}, +${o.newTransactions} txns, ${o.reconciled} reconciled, ${o.ruleCategorized} rule-categorized` + `sync connection ${o.connectionId}: ${ + o.ok ? "ok" : `FAILED (${o.error})` + }, +${o.newTransactions} txns, ${o.reconciled} reconciled, ${o.ruleCategorized} rule-categorized`, ); } }); } catch (err) { - console.warn('Deno.cron unavailable; scheduled sync disabled:', err); + console.warn("Deno.cron unavailable; scheduled sync disabled:", err); } }; -export const SESSION_COOKIE = 'quantum_session'; +export const SESSION_COOKIE = "quantum_session"; /** Routes reachable without a session: login, OAuth plumbing, health check. */ const PUBLIC_PATHS = new Set([ - '/login', - '/oauth/callback', - '/client-metadata.json', - '/jwks.json', - '/healthz' + "/login", + "/oauth/callback", + "/client-metadata.json", + "/jwks.json", + "/healthz", ]); export const handle: Handle = async ({ event, resolve }) => { @@ -45,10 +50,10 @@ export const handle: Handle = async ({ event, resolve }) => { const path = event.url.pathname; if (!event.locals.user && !PUBLIC_PATHS.has(path)) { - redirect(303, '/login'); + redirect(303, "/login"); } - if (event.locals.user && path === '/login') { - redirect(303, '/'); + if (event.locals.user && path === "/login") { + redirect(303, "/"); } return resolve(event); diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg index cc5dc66..c87e56d 100644 --- a/src/lib/assets/favicon.svg +++ b/src/lib/assets/favicon.svg @@ -1 +1,10 @@ -svelte-logo \ No newline at end of file + + svelte-logo + + + diff --git a/src/lib/components/Banner.svelte b/src/lib/components/Banner.svelte index fc7827e..beb0782 100644 --- a/src/lib/components/Banner.svelte +++ b/src/lib/components/Banner.svelte @@ -1,5 +1,5 @@ diff --git a/src/lib/components/RuleTray.svelte b/src/lib/components/RuleTray.svelte index d27afe4..6f9eac9 100644 --- a/src/lib/components/RuleTray.svelte +++ b/src/lib/components/RuleTray.svelte @@ -1,39 +1,39 @@ @@ -115,105 +115,105 @@ diff --git a/src/lib/format.ts b/src/lib/format.ts index d3f61b4..e2ffa95 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,30 +1,36 @@ const formatters = new Map(); -export function formatCents(cents: number, currency = 'USD'): string { +export function formatCents(cents: number, currency = "USD"): string { let fmt = formatters.get(currency); if (!fmt) { - fmt = new Intl.NumberFormat(undefined, { style: 'currency', currency }); + fmt = new Intl.NumberFormat(undefined, { style: "currency", currency }); formatters.set(currency, fmt); } return fmt.format(cents / 100); } -const dateFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }); +const dateFmt = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", +}); const dateFmtFull = new Intl.DateTimeFormat(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric' + year: "numeric", + month: "short", + day: "numeric", }); export function formatDay(unixSeconds: number): string { const date = new Date(unixSeconds * 1000); const now = new Date(); - return date.getFullYear() === now.getFullYear() ? dateFmt.format(date) : dateFmtFull.format(date); + return date.getFullYear() === now.getFullYear() + ? dateFmt.format(date) + : dateFmtFull.format(date); } export function formatMonth(month: string): string { - const [y, m] = month.split('-').map(Number); - return new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }).format( - new Date(Date.UTC(y, m - 1, 15)) - ); + const [y, m] = month.split("-").map(Number); + return new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" }) + .format( + new Date(Date.UTC(y, m - 1, 15)), + ); } diff --git a/src/lib/server/README.md b/src/lib/server/README.md index ce7dbf3..e653ee7 100644 --- a/src/lib/server/README.md +++ b/src/lib/server/README.md @@ -14,9 +14,9 @@ rules: logic in routes. When native clients arrive, `/api/*` JSON routes become a second adapter over the same services. 3. **No SQL outside `services/` and `db.ts`.** -4. **Relative imports within `src/lib/server/`** (not `$lib/...`) so modules - and their tests run under plain `deno test` without Vite alias resolution. -5. **Sessions are opaque tokens.** Delivered via HTTP-only cookie today; - the token format must never assume cookie transport (bearer support later). +4. **Relative imports within `src/lib/server/`** (not `$lib/...`) so modules and + their tests run under plain `deno test` without Vite alias resolution. +5. **Sessions are opaque tokens.** Delivered via HTTP-only cookie today; the + token format must never assume cookie transport (bearer support later). 6. **Money is integer cents.** Conversion from SimpleFIN's numeric strings happens exactly once, at normalization. Floats never touch amounts. diff --git a/src/lib/server/auth/oauth-client.ts b/src/lib/server/auth/oauth-client.ts index 3f226ca..3e9a323 100644 --- a/src/lib/server/auth/oauth-client.ts +++ b/src/lib/server/auth/oauth-client.ts @@ -3,44 +3,49 @@ import { type NodeSavedSession, type NodeSavedSessionStore, type NodeSavedState, - type NodeSavedStateStore -} from '@atproto/oauth-client-node'; -import { JoseKey } from '@atproto/jwk-jose'; -import { AtprotoDohHandleResolver } from '@atproto-labs/handle-resolver'; -import { buildAtprotoLoopbackClientMetadata } from '@atproto/oauth-types'; -import type { DatabaseSync } from 'node:sqlite'; -import type { Config } from '../config.ts'; + type NodeSavedStateStore, +} from "@atproto/oauth-client-node"; +import { JoseKey } from "@atproto/jwk-jose"; +import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; +import { buildAtprotoLoopbackClientMetadata } from "@atproto/oauth-types"; +import type { DatabaseSync } from "node:sqlite"; +import type { Config } from "../config.ts"; async function clientIdentity(config: Config) { - if (config.appUrl.startsWith('https:')) { - const key = await JoseKey.fromJWK(JSON.stringify(config.oauthPrivateKeyJwk)); + if (config.appUrl.startsWith("https:")) { + const key = await JoseKey.fromJWK( + JSON.stringify(config.oauthPrivateKeyJwk), + ); return { clientMetadata: { client_id: `${config.appUrl}/client-metadata.json`, - client_name: 'Quantum', + client_name: "Quantum", client_uri: config.appUrl, redirect_uris: [`${config.appUrl}/oauth/callback`] as [string], - grant_types: ['authorization_code', 'refresh_token'] as ['authorization_code', 'refresh_token'], - response_types: ['code'] as ['code'], - scope: 'atproto', - application_type: 'web' as const, - token_endpoint_auth_method: 'private_key_jwt' as const, - token_endpoint_auth_signing_alg: 'ES256', + grant_types: ["authorization_code", "refresh_token"] as [ + "authorization_code", + "refresh_token", + ], + response_types: ["code"] as ["code"], + scope: "atproto", + application_type: "web" as const, + token_endpoint_auth_method: "private_key_jwt" as const, + token_endpoint_auth_signing_alg: "ES256", dpop_bound_access_tokens: true as const, - jwks_uri: `${config.appUrl}/jwks.json` + jwks_uri: `${config.appUrl}/jwks.json`, }, - keyset: [key] + keyset: [key], }; } // Local development (http://localhost:PORT): atproto's loopback-client // exception. Public client, no keyset; redirect goes to 127.0.0.1. - const port = new URL(config.appUrl).port || '80'; + const port = new URL(config.appUrl).port || "80"; return { clientMetadata: buildAtprotoLoopbackClientMetadata({ - scope: 'atproto', - redirect_uris: [`http://127.0.0.1:${port}/oauth/callback`] + scope: "atproto", + redirect_uris: [`http://127.0.0.1:${port}/oauth/callback`], }), - keyset: undefined + keyset: undefined, }; } @@ -49,18 +54,20 @@ function stateStore(db: DatabaseSync): NodeSavedStateStore { set(key: string, state: NodeSavedState) { db.prepare( `INSERT INTO oauth_state (key, data, created_at) VALUES (?, ?, ?) - ON CONFLICT (key) DO UPDATE SET data = excluded.data` + ON CONFLICT (key) DO UPDATE SET data = excluded.data`, ).run(key, JSON.stringify(state), new Date().toISOString()); }, get(key: string) { - const row = db.prepare('SELECT data FROM oauth_state WHERE key = ?').get(key) as + const row = db.prepare("SELECT data FROM oauth_state WHERE key = ?").get( + key, + ) as | { data: string } | undefined; return row ? (JSON.parse(row.data) as NodeSavedState) : undefined; }, del(key: string) { - db.prepare('DELETE FROM oauth_state WHERE key = ?').run(key); - } + db.prepare("DELETE FROM oauth_state WHERE key = ?").run(key); + }, }; } @@ -70,24 +77,25 @@ function sessionStore(db: DatabaseSync): NodeSavedSessionStore { const now = new Date().toISOString(); db.prepare( `INSERT INTO oauth_session (key, data, created_at, updated_at) VALUES (?, ?, ?, ?) - ON CONFLICT (key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at` + ON CONFLICT (key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`, ).run(key, JSON.stringify(session), now, now); }, get(key: string) { - const row = db.prepare('SELECT data FROM oauth_session WHERE key = ?').get(key) as - | { data: string } - | undefined; + const row = db.prepare("SELECT data FROM oauth_session WHERE key = ?") + .get(key) as + | { data: string } + | undefined; return row ? (JSON.parse(row.data) as NodeSavedSession) : undefined; }, del(key: string) { - db.prepare('DELETE FROM oauth_session WHERE key = ?').run(key); - } + db.prepare("DELETE FROM oauth_session WHERE key = ?").run(key); + }, }; } export async function createOAuthClient( config: Config, - db: DatabaseSync + db: DatabaseSync, ): Promise { const identity = await clientIdentity(config); return new NodeOAuthClient({ @@ -95,25 +103,32 @@ export async function createOAuthClient( // undici internals that Deno does not emulate (process.versions.undici). // DNS-over-HTTPS resolution is pure fetch and runs fine under Deno. handleResolver: new AtprotoDohHandleResolver({ - dohEndpoint: 'https://mozilla.cloudflare-dns.com/dns-query' + dohEndpoint: "https://mozilla.cloudflare-dns.com/dns-query", }), ...identity, stateStore: stateStore(db), - sessionStore: sessionStore(db) + sessionStore: sessionStore(db), }); } let instance: NodeOAuthClient | null = null; let instancePromise: Promise | null = null; -export function initOAuthClient(config: Config, db: DatabaseSync): Promise { - instancePromise ??= createOAuthClient(config, db).then((client) => (instance = client)); +export function initOAuthClient( + config: Config, + db: DatabaseSync, +): Promise { + instancePromise ??= createOAuthClient(config, db).then(( + client, + ) => (instance = client)); return instancePromise; } export function getOAuthClient(): NodeOAuthClient { if (!instance) { - throw new Error('OAuth client not initialized — initOAuthClient() runs at server startup'); + throw new Error( + "OAuth client not initialized — initOAuthClient() runs at server startup", + ); } return instance; } diff --git a/src/lib/server/config.test.ts b/src/lib/server/config.test.ts index b4e69aa..1dec8e4 100644 --- a/src/lib/server/config.test.ts +++ b/src/lib/server/config.test.ts @@ -1,59 +1,72 @@ /// -import { loadConfig } from './config.ts'; +import { loadConfig } from "./config.ts"; const VALID_JWK = JSON.stringify({ - kty: 'EC', - crv: 'P-256', - d: 'x', - x: 'x', - y: 'y', - kid: 'test-key' + kty: "EC", + crv: "P-256", + d: "x", + x: "x", + y: "y", + kid: "test-key", }); const VALID_ENV = { - APP_URL: 'https://quantum.example.com', - ALLOWED_DIDS: 'did:plc:aaa, did:plc:bbb', - DB_PATH: './data/test.db', - OAUTH_PRIVATE_KEY_JWK: VALID_JWK + APP_URL: "https://quantum.example.com", + ALLOWED_DIDS: "did:plc:aaa, did:plc:bbb", + DB_PATH: "./data/test.db", + OAUTH_PRIVATE_KEY_JWK: VALID_JWK, }; -Deno.test('valid env parses', () => { +Deno.test("valid env parses", () => { const config = loadConfig(VALID_ENV); - if (config.appUrl !== 'https://quantum.example.com') throw new Error('appUrl mismatch'); - if (config.allowedDids.length !== 2 || config.allowedDids[1] !== 'did:plc:bbb') { + if (config.appUrl !== "https://quantum.example.com") { + throw new Error("appUrl mismatch"); + } + if ( + config.allowedDids.length !== 2 || config.allowedDids[1] !== "did:plc:bbb" + ) { throw new Error(`allowedDids mismatch: ${config.allowedDids}`); } }); -Deno.test('missing env reports every problem at once', () => { +Deno.test("missing env reports every problem at once", () => { try { loadConfig({}); - throw new Error('expected loadConfig to throw'); + throw new Error("expected loadConfig to throw"); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - for (const name of ['APP_URL', 'ALLOWED_DIDS', 'DB_PATH', 'OAUTH_PRIVATE_KEY_JWK']) { - if (!msg.includes(name)) throw new Error(`error message missing ${name}: ${msg}`); + for ( + const name of [ + "APP_URL", + "ALLOWED_DIDS", + "DB_PATH", + "OAUTH_PRIVATE_KEY_JWK", + ] + ) { + if (!msg.includes(name)) { + throw new Error(`error message missing ${name}: ${msg}`); + } } } }); -Deno.test('http APP_URL rejected unless localhost', () => { +Deno.test("http APP_URL rejected unless localhost", () => { let threw = false; try { - loadConfig({ ...VALID_ENV, APP_URL: 'http://quantum.example.com' }); + loadConfig({ ...VALID_ENV, APP_URL: "http://quantum.example.com" }); } catch { threw = true; } - if (!threw) throw new Error('expected http APP_URL to be rejected'); - loadConfig({ ...VALID_ENV, APP_URL: 'http://localhost:5173' }); // must not throw + if (!threw) throw new Error("expected http APP_URL to be rejected"); + loadConfig({ ...VALID_ENV, APP_URL: "http://localhost:5173" }); // must not throw }); -Deno.test('non-DID allowlist entry rejected', () => { +Deno.test("non-DID allowlist entry rejected", () => { let threw = false; try { - loadConfig({ ...VALID_ENV, ALLOWED_DIDS: 'did:plc:aaa,alice.example.com' }); + loadConfig({ ...VALID_ENV, ALLOWED_DIDS: "did:plc:aaa,alice.example.com" }); } catch { threw = true; } - if (!threw) throw new Error('expected non-DID entry to be rejected'); + if (!threw) throw new Error("expected non-DID entry to be rejected"); }); diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index 41a2555..77a6f57 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -1,4 +1,4 @@ -import process from 'node:process'; +import process from "node:process"; export interface Config { /** Public HTTPS origin, no trailing slash. Drives OAuth client_id, redirect URI, JWKS URL. */ @@ -11,18 +11,24 @@ export interface Config { oauthPrivateKeyJwk: Record; } -export function loadConfig(env: Record = process.env): Config { +export function loadConfig( + env: Record = process.env, +): Config { const problems: string[] = []; const appUrlRaw = env.APP_URL?.trim(); - let appUrl = ''; + let appUrl = ""; if (!appUrlRaw) { - problems.push('APP_URL is required (public origin, e.g. https://quantum.example.com)'); + problems.push( + "APP_URL is required (public origin, e.g. https://quantum.example.com)", + ); } else { try { const parsed = new URL(appUrlRaw); - if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost') { - problems.push(`APP_URL must be https (got ${parsed.protocol}//) unless localhost`); + if (parsed.protocol !== "https:" && parsed.hostname !== "localhost") { + problems.push( + `APP_URL must be https (got ${parsed.protocol}//) unless localhost`, + ); } appUrl = parsed.origin; } catch { @@ -30,42 +36,54 @@ export function loadConfig(env: Record = process.env } } - const allowedDids = (env.ALLOWED_DIDS ?? '') - .split(',') + const allowedDids = (env.ALLOWED_DIDS ?? "") + .split(",") .map((d) => d.trim()) .filter((d) => d.length > 0); if (allowedDids.length === 0) { - problems.push('ALLOWED_DIDS is required (comma-separated DIDs, e.g. did:plc:abc,did:plc:def)'); + problems.push( + "ALLOWED_DIDS is required (comma-separated DIDs, e.g. did:plc:abc,did:plc:def)", + ); } else { for (const did of allowedDids) { - if (!did.startsWith('did:')) problems.push(`ALLOWED_DIDS entry is not a DID: ${did}`); + if (!did.startsWith("did:")) { + problems.push(`ALLOWED_DIDS entry is not a DID: ${did}`); + } } } const dbPath = env.DB_PATH?.trim(); - if (!dbPath) problems.push('DB_PATH is required (path to the SQLite database file)'); + if (!dbPath) { + problems.push("DB_PATH is required (path to the SQLite database file)"); + } let oauthPrivateKeyJwk: Record = {}; const jwkRaw = env.OAUTH_PRIVATE_KEY_JWK?.trim(); if (!jwkRaw) { problems.push( - 'OAUTH_PRIVATE_KEY_JWK is required (ES256 private JWK; generate with `deno task generate-key`)' + "OAUTH_PRIVATE_KEY_JWK is required (ES256 private JWK; generate with `deno task generate-key`)", ); } else { try { oauthPrivateKeyJwk = JSON.parse(jwkRaw); - if (oauthPrivateKeyJwk.kty !== 'EC' || oauthPrivateKeyJwk.crv !== 'P-256') { - problems.push('OAUTH_PRIVATE_KEY_JWK must be an EC P-256 (ES256) key'); + if ( + oauthPrivateKeyJwk.kty !== "EC" || oauthPrivateKeyJwk.crv !== "P-256" + ) { + problems.push("OAUTH_PRIVATE_KEY_JWK must be an EC P-256 (ES256) key"); + } + if (!oauthPrivateKeyJwk.d) { + problems.push("OAUTH_PRIVATE_KEY_JWK must be a private key"); + } + if (!oauthPrivateKeyJwk.kid) { + problems.push("OAUTH_PRIVATE_KEY_JWK must include a kid"); } - if (!oauthPrivateKeyJwk.d) problems.push('OAUTH_PRIVATE_KEY_JWK must be a private key'); - if (!oauthPrivateKeyJwk.kid) problems.push('OAUTH_PRIVATE_KEY_JWK must include a kid'); } catch { - problems.push('OAUTH_PRIVATE_KEY_JWK is not valid JSON'); + problems.push("OAUTH_PRIVATE_KEY_JWK is not valid JSON"); } } if (problems.length > 0) { - throw new Error(`Invalid configuration:\n - ${problems.join('\n - ')}`); + throw new Error(`Invalid configuration:\n - ${problems.join("\n - ")}`); } return { appUrl, allowedDids, dbPath: dbPath!, oauthPrivateKeyJwk }; diff --git a/src/lib/server/db.test.ts b/src/lib/server/db.test.ts index 964c910..dfc66b8 100644 --- a/src/lib/server/db.test.ts +++ b/src/lib/server/db.test.ts @@ -1,119 +1,139 @@ /// -import { openDatabase, runMigrations } from './db.ts'; +import { openDatabase, runMigrations } from "./db.ts"; -const MIGRATIONS_DIR = new URL('../../../migrations', import.meta.url).pathname.replace( - /^\/([A-Za-z]:)/, - '$1' -); +const MIGRATIONS_DIR = new URL("../../../migrations", import.meta.url).pathname + .replace( + /^\/([A-Za-z]:)/, + "$1", + ); -Deno.test('migrations apply once and are idempotent across runs', () => { - const dbPath = join(Deno.makeTempDirSync(), 'test.db'); +Deno.test("migrations apply once and are idempotent across runs", () => { + const dbPath = join(Deno.makeTempDirSync(), "test.db"); const db = openDatabase(dbPath, MIGRATIONS_DIR); - const versions = db.prepare('SELECT version, name FROM schema_version ORDER BY version').all(); - if (versions.length === 0) throw new Error('no migrations applied'); + const versions = db.prepare( + "SELECT version, name FROM schema_version ORDER BY version", + ).all(); + if (versions.length === 0) throw new Error("no migrations applied"); // second run applies nothing const applied = runMigrations(db, MIGRATIONS_DIR); - if (applied !== 0) throw new Error(`expected 0 re-applied migrations, got ${applied}`); + if (applied !== 0) { + throw new Error(`expected 0 re-applied migrations, got ${applied}`); + } db.close(); }); -Deno.test('built-in Transfer category is seeded', () => { - const dbPath = join(Deno.makeTempDirSync(), 'test.db'); +Deno.test("built-in Transfer category is seeded", () => { + const dbPath = join(Deno.makeTempDirSync(), "test.db"); const db = openDatabase(dbPath, MIGRATIONS_DIR); const row = db - .prepare('SELECT name, kind, builtin FROM categories WHERE name = ?') - .get('Transfer') as { name: string; kind: string; builtin: number } | undefined; - if (!row || row.kind !== 'transfer' || row.builtin !== 1) { - throw new Error(`Transfer category missing or wrong: ${JSON.stringify(row)}`); + .prepare("SELECT name, kind, builtin FROM categories WHERE name = ?") + .get("Transfer") as + | { name: string; kind: string; builtin: number } + | undefined; + if (!row || row.kind !== "transfer" || row.builtin !== 1) { + throw new Error( + `Transfer category missing or wrong: ${JSON.stringify(row)}`, + ); } db.close(); }); -Deno.test('foreign keys are enforced', () => { - const dbPath = join(Deno.makeTempDirSync(), 'test.db'); +Deno.test("foreign keys are enforced", () => { + const dbPath = join(Deno.makeTempDirSync(), "test.db"); const db = openDatabase(dbPath, MIGRATIONS_DIR); let threw = false; try { db.prepare( `INSERT INTO transactions (account_id, sfin_id, amount_cents, description, created_at) - VALUES ('nonexistent', 't1', 100, 'x', ?)` + VALUES ('nonexistent', 't1', 100, 'x', ?)`, ).run(new Date().toISOString()); } catch { threw = true; } - if (!threw) throw new Error('expected FK violation'); + if (!threw) throw new Error("expected FK violation"); db.close(); }); -Deno.test('csv import migration applies to a populated database', () => { +Deno.test("csv import migration applies to a populated database", () => { // Stage only the migrations before 005, populate, then upgrade — the path an // existing install actually takes. const stageDir = Deno.makeTempDirSync(); for (const entry of Deno.readDirSync(MIGRATIONS_DIR)) { const version = Number(entry.name.match(/^(\d+)_/)?.[1]); if (Number.isFinite(version) && version < 5) { - Deno.copyFileSync(join(MIGRATIONS_DIR, entry.name), join(stageDir, entry.name)); + Deno.copyFileSync( + join(MIGRATIONS_DIR, entry.name), + join(stageDir, entry.name), + ); } } - const dbPath = join(Deno.makeTempDirSync(), 'test.db'); + const dbPath = join(Deno.makeTempDirSync(), "test.db"); const db = openDatabase(dbPath, stageDir); const now = new Date().toISOString(); - db.prepare('INSERT INTO connections (access_url, claimed_at) VALUES (?, ?)').run( - 'https://example.test', - now - ); + db.prepare("INSERT INTO connections (access_url, claimed_at) VALUES (?, ?)") + .run( + "https://example.test", + now, + ); db.prepare( `INSERT INTO accounts (id, connection_id, name, currency, state, created_at) - VALUES ('acct-1', 1, 'Checking', 'USD', 'ACTIVE', ?)` + VALUES ('acct-1', 1, 'Checking', 'USD', 'ACTIVE', ?)`, ).run(now); db.prepare( `INSERT INTO transactions (account_id, sfin_id, posted, amount_cents, description, created_at) - VALUES ('acct-1', 'sfin-1', 1700000000, -1234, 'COFFEE', ?)` + VALUES ('acct-1', 'sfin-1', 1700000000, -1234, 'COFFEE', ?)`, ).run(now); const applied = runMigrations(db, MIGRATIONS_DIR); - if (applied !== 1) throw new Error(`expected only 005 to apply, got ${applied}`); + if (applied !== 1) { + throw new Error(`expected only 005 to apply, got ${applied}`); + } - const row = db.prepare("SELECT import_id FROM transactions WHERE sfin_id = 'sfin-1'").get() as { + const row = db.prepare( + "SELECT import_id FROM transactions WHERE sfin_id = 'sfin-1'", + ).get() as { import_id: number | null; }; if (row.import_id !== null) { - throw new Error(`pre-existing rows must read back as synced, got ${row.import_id}`); + throw new Error( + `pre-existing rows must read back as synced, got ${row.import_id}`, + ); } db.close(); }); -Deno.test('imports status is a closed enum', () => { - const dbPath = join(Deno.makeTempDirSync(), 'test.db'); +Deno.test("imports status is a closed enum", () => { + const dbPath = join(Deno.makeTempDirSync(), "test.db"); const db = openDatabase(dbPath, MIGRATIONS_DIR); const now = new Date().toISOString(); - db.prepare('INSERT INTO connections (access_url, claimed_at) VALUES (?, ?)').run( - 'https://example.test', - now - ); + db.prepare("INSERT INTO connections (access_url, claimed_at) VALUES (?, ?)") + .run( + "https://example.test", + now, + ); db.prepare( `INSERT INTO accounts (id, connection_id, name, currency, state, created_at) - VALUES ('acct-1', 1, 'Checking', 'USD', 'ACTIVE', ?)` + VALUES ('acct-1', 1, 'Checking', 'USD', 'ACTIVE', ?)`, ).run(now); let threw = false; try { db.prepare( `INSERT INTO imports (account_id, uploaded_at, payload, status) - VALUES ('acct-1', ?, 'raw', 'bogus')` + VALUES ('acct-1', ?, 'raw', 'bogus')`, ).run(now); } catch { threw = true; } - if (!threw) throw new Error('expected status CHECK violation'); + if (!threw) throw new Error("expected status CHECK violation"); db.close(); }); function join(...parts: string[]) { - return parts.join('/'); + return parts.join("/"); } diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index d0d546b..9da68c5 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -1,7 +1,7 @@ -import { DatabaseSync } from 'node:sqlite'; -import { mkdirSync, readFileSync, readdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import process from 'node:process'; +import { DatabaseSync } from "node:sqlite"; +import { mkdirSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import process from "node:process"; const MIGRATION_FILE = /^(\d+)_.*\.sql$/; @@ -14,28 +14,37 @@ export function initDb(dbPath: string, migrationsDir: string): DatabaseSync { } export function getDb(): DatabaseSync { - if (!instance) throw new Error('Database not initialized — initDb() runs at server startup'); + if (!instance) { + throw new Error( + "Database not initialized — initDb() runs at server startup", + ); + } return instance; } -export function openDatabase(dbPath: string, migrationsDir: string): DatabaseSync { +export function openDatabase( + dbPath: string, + migrationsDir: string, +): DatabaseSync { let db: DatabaseSync; try { - if (dbPath !== ':memory:') mkdirSync(dirname(dbPath), { recursive: true }); + if (dbPath !== ":memory:") mkdirSync(dirname(dbPath), { recursive: true }); db = new DatabaseSync(dbPath); } catch (err) { const reason = err instanceof Error ? err.message : String(err); throw new Error( `Cannot open database at ${dbPath}: ${reason}\n` + - `The server runs as uid ${process.getuid?.() ?? '?'} — the directory must be ` + + `The server runs as uid ${ + process.getuid?.() ?? "?" + } — the directory must be ` + `writable by it. In a container, bind mounts keep host ownership: with rootless ` + `Podman use "-v /host/path:/data:U" (chowns to the container user) or ` + `"podman unshare chown -R 1000:1000 /host/path"; a named volume ` + - `(-v quantum-data:/data) inherits correct ownership from the image automatically.` + `(-v quantum-data:/data) inherits correct ownership from the image automatically.`, ); } - db.exec('PRAGMA journal_mode = WAL'); - db.exec('PRAGMA foreign_keys = ON'); + db.exec("PRAGMA journal_mode = WAL"); + db.exec("PRAGMA foreign_keys = ON"); runMigrations(db, migrationsDir); return db; } @@ -48,9 +57,11 @@ export function runMigrations(db: DatabaseSync, migrationsDir: string): number { )`); const applied = new Set( - (db.prepare('SELECT version FROM schema_version').all() as { version: number }[]).map( - (r) => r.version - ) + (db.prepare("SELECT version FROM schema_version").all() as { + version: number; + }[]).map( + (r) => r.version, + ), ); const migrations = readdirSync(migrationsDir) @@ -64,19 +75,23 @@ export function runMigrations(db: DatabaseSync, migrationsDir: string): number { let count = 0; for (const { version, file } of migrations) { if (applied.has(version)) continue; - const sql = readFileSync(join(migrationsDir, file), 'utf-8'); - db.exec('BEGIN'); + const sql = readFileSync(join(migrationsDir, file), "utf-8"); + db.exec("BEGIN"); try { db.exec(sql); - db.prepare('INSERT INTO schema_version (version, name, applied_at) VALUES (?, ?, ?)').run( + db.prepare( + "INSERT INTO schema_version (version, name, applied_at) VALUES (?, ?, ?)", + ).run( version, file, - new Date().toISOString() + new Date().toISOString(), ); - db.exec('COMMIT'); + db.exec("COMMIT"); } catch (err) { - db.exec('ROLLBACK'); - throw new Error(`Migration ${file} failed: ${err instanceof Error ? err.message : err}`); + db.exec("ROLLBACK"); + throw new Error( + `Migration ${file} failed: ${err instanceof Error ? err.message : err}`, + ); } count++; } diff --git a/src/lib/server/services/accounts.ts b/src/lib/server/services/accounts.ts index 3f158ff..5a577dc 100644 --- a/src/lib/server/services/accounts.ts +++ b/src/lib/server/services/accounts.ts @@ -1,15 +1,21 @@ -import type { DatabaseSync } from 'node:sqlite'; +import type { DatabaseSync } from "node:sqlite"; -export type AccountState = 'NEW' | 'ACTIVE' | 'INACTIVE' | 'HIDDEN'; -export type AccountType = 'checking' | 'savings' | 'credit' | 'investment' | 'loan' | 'other'; +export type AccountState = "NEW" | "ACTIVE" | "INACTIVE" | "HIDDEN"; +export type AccountType = + | "checking" + | "savings" + | "credit" + | "investment" + | "loan" + | "other"; export const ACCOUNT_TYPES: AccountType[] = [ - 'checking', - 'savings', - 'credit', - 'investment', - 'loan', - 'other' + "checking", + "savings", + "credit", + "investment", + "loan", + "other", ]; export interface AccountView { @@ -35,7 +41,7 @@ export function listAccounts(db: DatabaseSync): AccountView[] { SELECT id FROM balance_snapshots WHERE account_id = a.id ORDER BY captured_at DESC, id DESC LIMIT 1 ) - ORDER BY a.org_name, a.name` + ORDER BY a.org_name, a.name`, ) .all() as Record[]; return rows.map((r) => ({ @@ -48,7 +54,7 @@ export function listAccounts(db: DatabaseSync): AccountView[] { accountType: r.account_type as AccountType | null, lastSuccessfulDataAt: r.last_successful_data_at as string | null, balanceCents: r.balance_cents as number | null, - balanceCapturedAt: r.captured_at as string | null + balanceCapturedAt: r.captured_at as string | null, })); } @@ -57,23 +63,29 @@ export function classifyAccount( db: DatabaseSync, id: string, accountType: AccountType, - displayName: string | null + displayName: string | null, ): void { - if (!ACCOUNT_TYPES.includes(accountType)) throw new Error(`Invalid account type: ${accountType}`); + if (!ACCOUNT_TYPES.includes(accountType)) { + throw new Error(`Invalid account type: ${accountType}`); + } db.prepare( `UPDATE accounts SET account_type = ?, display_name = ?, state = CASE WHEN state IN ('NEW','INACTIVE') THEN 'ACTIVE' ELSE state END - WHERE id = ?` + WHERE id = ?`, ).run(accountType, displayName, id); } -export function setAccountHidden(db: DatabaseSync, id: string, hidden: boolean): void { +export function setAccountHidden( + db: DatabaseSync, + id: string, + hidden: boolean, +): void { if (hidden) { db.prepare("UPDATE accounts SET state = 'HIDDEN' WHERE id = ?").run(id); } else { db.prepare( `UPDATE accounts SET state = CASE WHEN account_type IS NULL THEN 'NEW' ELSE 'ACTIVE' END - WHERE id = ? AND state = 'HIDDEN'` + WHERE id = ? AND state = 'HIDDEN'`, ).run(id); } } @@ -86,19 +98,22 @@ export interface StaleAccount { } /** ACTIVE accounts whose data is older than the threshold (default 48h). */ -export function listStaleAccounts(db: DatabaseSync, thresholdHours = 48): StaleAccount[] { +export function listStaleAccounts( + db: DatabaseSync, + thresholdHours = 48, +): StaleAccount[] { const cutoff = new Date(Date.now() - thresholdHours * 3600_000).toISOString(); const rows = db .prepare( `SELECT id, COALESCE(display_name, name) AS label, org_name, last_successful_data_at FROM accounts WHERE state = 'ACTIVE' - AND (last_successful_data_at IS NULL OR last_successful_data_at < ?)` + AND (last_successful_data_at IS NULL OR last_successful_data_at < ?)`, ) .all(cutoff) as Record[]; return rows.map((r) => ({ id: r.id as string, label: r.label as string, orgName: r.org_name as string | null, - lastSuccessfulDataAt: r.last_successful_data_at as string | null + lastSuccessfulDataAt: r.last_successful_data_at as string | null, })); } diff --git a/src/lib/server/services/categories.test.ts b/src/lib/server/services/categories.test.ts index 3f5f4dc..fccb7cb 100644 --- a/src/lib/server/services/categories.test.ts +++ b/src/lib/server/services/categories.test.ts @@ -1,16 +1,21 @@ /// -import { openDatabase } from '../db.ts'; -import { createCategory, listCategories, setCategoryActive } from './categories.ts'; +import { openDatabase } from "../db.ts"; +import { + createCategory, + listCategories, + setCategoryActive, +} from "./categories.ts"; -const MIGRATIONS_DIR = new URL('../../../../migrations', import.meta.url).pathname.replace( - /^\/([A-Za-z]:)/, - '$1' -); +const MIGRATIONS_DIR = new URL("../../../../migrations", import.meta.url) + .pathname.replace( + /^\/([A-Za-z]:)/, + "$1", + ); -Deno.test('built-in Transfer category cannot be deactivated; others can', () => { +Deno.test("built-in Transfer category cannot be deactivated; others can", () => { const db = openDatabase(`${Deno.makeTempDirSync()}/t.db`, MIGRATIONS_DIR); const transfer = listCategories(db).find((c) => c.builtin); - if (!transfer) throw new Error('built-in Transfer missing'); + if (!transfer) throw new Error("built-in Transfer missing"); let threw = false; try { @@ -18,27 +23,29 @@ Deno.test('built-in Transfer category cannot be deactivated; others can', () => } catch { threw = true; } - if (!threw) throw new Error('deactivating built-in Transfer should throw'); + if (!threw) throw new Error("deactivating built-in Transfer should throw"); if (!listCategories(db).find((c) => c.id === transfer.id)?.active) { - throw new Error('Transfer must remain active'); + throw new Error("Transfer must remain active"); } - const groceries = createCategory(db, 'Groceries', 'expense'); + const groceries = createCategory(db, "Groceries", "expense"); setCategoryActive(db, groceries.id, false); if (listCategories(db).find((c) => c.id === groceries.id)?.active) { - throw new Error('regular category should deactivate'); + throw new Error("regular category should deactivate"); } setCategoryActive(db, groceries.id, true); // and reactivate db.close(); }); -Deno.test('created categories get distinct least-used color indices', () => { +Deno.test("created categories get distinct least-used color indices", () => { const db = openDatabase(`${Deno.makeTempDirSync()}/t.db`, MIGRATIONS_DIR); const seen = new Set(listCategories(db).map((c) => c.colorIndex)); for (let i = 0; i < 11; i++) { - const category = createCategory(db, `Cat ${i}`, 'expense'); + const category = createCategory(db, `Cat ${i}`, "expense"); if (seen.has(category.colorIndex)) { - throw new Error(`color index ${category.colorIndex} reused while slots remained`); + throw new Error( + `color index ${category.colorIndex} reused while slots remained`, + ); } seen.add(category.colorIndex); } diff --git a/src/lib/server/services/categories.ts b/src/lib/server/services/categories.ts index 5244120..cd1fa77 100644 --- a/src/lib/server/services/categories.ts +++ b/src/lib/server/services/categories.ts @@ -1,6 +1,6 @@ -import type { DatabaseSync } from 'node:sqlite'; +import type { DatabaseSync } from "node:sqlite"; -export type CategoryKind = 'income' | 'expense' | 'transfer'; +export type CategoryKind = "income" | "expense" | "transfer"; export const CATEGORY_PALETTE_SIZE = 12; @@ -16,13 +16,13 @@ export interface Category { export function listCategories( db: DatabaseSync, - options: { activeOnly?: boolean } = {} + options: { activeOnly?: boolean } = {}, ): Category[] { const rows = db .prepare( `SELECT id, name, kind, builtin, active, color_index FROM categories - ${options.activeOnly ? 'WHERE active = 1' : ''} - ORDER BY kind, name` + ${options.activeOnly ? "WHERE active = 1" : ""} + ORDER BY kind, name`, ) .all() as Record[]; return rows.map((r) => ({ @@ -31,28 +31,39 @@ export function listCategories( kind: r.kind as CategoryKind, builtin: r.builtin === 1, active: r.active === 1, - colorIndex: (r.color_index as number | null) ?? ((r.id as number) - 1) % CATEGORY_PALETTE_SIZE + colorIndex: (r.color_index as number | null) ?? + ((r.id as number) - 1) % CATEGORY_PALETTE_SIZE, })); } /** Least-used palette slot (lowest index wins ties) so colors stay distinct as long as possible. */ function nextColorIndex(db: DatabaseSync): number { const rows = db - .prepare('SELECT color_index, COUNT(*) AS n FROM categories GROUP BY color_index') + .prepare( + "SELECT color_index, COUNT(*) AS n FROM categories GROUP BY color_index", + ) .all() as { color_index: number | null; n: number }[]; const usage = new Array(CATEGORY_PALETTE_SIZE).fill(0); for (const row of rows) { - if (row.color_index != null) usage[row.color_index % CATEGORY_PALETTE_SIZE] += row.n; + if (row.color_index != null) { + usage[row.color_index % CATEGORY_PALETTE_SIZE] += row.n; + } } return usage.indexOf(Math.min(...usage)); } -export function createCategory(db: DatabaseSync, name: string, kind: CategoryKind): Category { +export function createCategory( + db: DatabaseSync, + name: string, + kind: CategoryKind, +): Category { const trimmed = name.trim(); - if (!trimmed) throw new Error('Category name is required.'); + if (!trimmed) throw new Error("Category name is required."); const colorIndex = nextColorIndex(db); const result = db - .prepare('INSERT INTO categories (name, kind, color_index, created_at) VALUES (?, ?, ?, ?)') + .prepare( + "INSERT INTO categories (name, kind, color_index, created_at) VALUES (?, ?, ?, ?)", + ) .run(trimmed, kind, colorIndex, new Date().toISOString()); return { id: Number(result.lastInsertRowid), @@ -60,24 +71,37 @@ export function createCategory(db: DatabaseSync, name: string, kind: CategoryKin kind, builtin: false, active: true, - colorIndex + colorIndex, }; } -export function renameCategory(db: DatabaseSync, id: number, name: string): void { +export function renameCategory( + db: DatabaseSync, + id: number, + name: string, +): void { const trimmed = name.trim(); - if (!trimmed) throw new Error('Category name is required.'); - db.prepare('UPDATE categories SET name = ? WHERE id = ?').run(trimmed, id); + if (!trimmed) throw new Error("Category name is required."); + db.prepare("UPDATE categories SET name = ? WHERE id = ?").run(trimmed, id); } /** Deactivation hides a category from pickers; history keeps pointing at it. */ -export function setCategoryActive(db: DatabaseSync, id: number, active: boolean): void { - const row = db.prepare('SELECT builtin FROM categories WHERE id = ?').get(id) as +export function setCategoryActive( + db: DatabaseSync, + id: number, + active: boolean, +): void { + const row = db.prepare("SELECT builtin FROM categories WHERE id = ?").get( + id, + ) as | { builtin: number } | undefined; - if (!row) throw new Error('No such category.'); + if (!row) throw new Error("No such category."); if (row.builtin === 1 && !active) { - throw new Error('The built-in Transfer category cannot be deactivated.'); + throw new Error("The built-in Transfer category cannot be deactivated."); } - db.prepare('UPDATE categories SET active = ? WHERE id = ?').run(active ? 1 : 0, id); + db.prepare("UPDATE categories SET active = ? WHERE id = ?").run( + active ? 1 : 0, + id, + ); } diff --git a/src/lib/server/services/categorization.ts b/src/lib/server/services/categorization.ts index 1ee0c4c..0518e53 100644 --- a/src/lib/server/services/categorization.ts +++ b/src/lib/server/services/categorization.ts @@ -1,6 +1,6 @@ -import type { DatabaseSync } from 'node:sqlite'; +import type { DatabaseSync } from "node:sqlite"; -export type EventSource = 'rule' | 'manual' | 'reconciliation'; +export type EventSource = "rule" | "manual" | "reconciliation"; export interface CategorizationEventInput { transactionId: number; @@ -14,34 +14,37 @@ export interface CategorizationEventInput { * Append an immutable categorization event and update the denormalized * transactions.category_id cache in the same DB transaction (design D4). */ -export function appendCategorizationEvent(db: DatabaseSync, input: CategorizationEventInput): void { - if (input.source === 'rule' && input.ruleId == null) { - throw new Error('rule events require ruleId'); +export function appendCategorizationEvent( + db: DatabaseSync, + input: CategorizationEventInput, +): void { + if (input.source === "rule" && input.ruleId == null) { + throw new Error("rule events require ruleId"); } - if (input.source === 'manual' && !input.actorDid) { - throw new Error('manual events require actorDid'); + if (input.source === "manual" && !input.actorDid) { + throw new Error("manual events require actorDid"); } - db.exec('BEGIN'); + db.exec("BEGIN"); try { db.prepare( `INSERT INTO categorization_events (transaction_id, category_id, source, rule_id, actor_did, created_at) - VALUES (?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?)`, ).run( input.transactionId, input.categoryId, input.source, input.ruleId ?? null, input.actorDid ?? null, - new Date().toISOString() + new Date().toISOString(), ); - db.prepare('UPDATE transactions SET category_id = ? WHERE id = ?').run( + db.prepare("UPDATE transactions SET category_id = ? WHERE id = ?").run( input.categoryId, - input.transactionId + input.transactionId, ); - db.exec('COMMIT'); + db.exec("COMMIT"); } catch (err) { - db.exec('ROLLBACK'); + db.exec("ROLLBACK"); throw err; } } @@ -60,7 +63,10 @@ export interface CategorizationEvent { } /** Full provenance history for a transaction, oldest first. */ -export function listEvents(db: DatabaseSync, transactionId: number): CategorizationEvent[] { +export function listEvents( + db: DatabaseSync, + transactionId: number, +): CategorizationEvent[] { const rows = db .prepare( `SELECT e.id, e.transaction_id, e.category_id, c.name AS category_name, @@ -71,7 +77,7 @@ export function listEvents(db: DatabaseSync, transactionId: number): Categorizat LEFT JOIN rules r ON r.id = e.rule_id LEFT JOIN users u ON u.did = e.actor_did WHERE e.transaction_id = ? - ORDER BY e.id` + ORDER BY e.id`, ) .all(transactionId) as Record[]; return rows.map((r) => ({ @@ -84,7 +90,7 @@ export function listEvents(db: DatabaseSync, transactionId: number): Categorizat rulePattern: r.rule_pattern as string | null, actorDid: r.actor_did as string | null, actorHandle: r.actor_handle as string | null, - createdAt: r.created_at as string + createdAt: r.created_at as string, })); } @@ -96,12 +102,12 @@ export function categorizeManually( db: DatabaseSync, transactionId: number, categoryId: number | null, - actorDid: string + actorDid: string, ): void { appendCategorizationEvent(db, { transactionId, categoryId, - source: 'manual', - actorDid + source: "manual", + actorDid, }); } diff --git a/src/lib/server/services/connections.test.ts b/src/lib/server/services/connections.test.ts index c55aec9..ac2f287 100644 --- a/src/lib/server/services/connections.test.ts +++ b/src/lib/server/services/connections.test.ts @@ -1,35 +1,41 @@ /// -import { openDatabase } from '../db.ts'; -import { claimSetupToken, listConnections } from './connections.ts'; -import { ClaimError } from '../simplefin.ts'; +import { openDatabase } from "../db.ts"; +import { claimSetupToken, listConnections } from "./connections.ts"; +import { ClaimError } from "../simplefin.ts"; -const MIGRATIONS_DIR = new URL('../../../../migrations', import.meta.url).pathname.replace( - /^\/([A-Za-z]:)/, - '$1' -); +const MIGRATIONS_DIR = new URL("../../../../migrations", import.meta.url) + .pathname.replace( + /^\/([A-Za-z]:)/, + "$1", + ); -const CLAIM_URL = 'https://bridge.test/simplefin/claim/demo'; +const CLAIM_URL = "https://bridge.test/simplefin/claim/demo"; const TOKEN = btoa(CLAIM_URL); -const ACCESS_URL = 'https://user:pass@bridge.test/simplefin'; +const ACCESS_URL = "https://user:pass@bridge.test/simplefin"; -Deno.test('valid setup token is claimed and the access URL stored', async () => { +Deno.test("valid setup token is claimed and the access URL stored", async () => { const db = openDatabase(`${Deno.makeTempDirSync()}/t.db`, MIGRATIONS_DIR); const fakeFetch = ((input: RequestInfo | URL, init?: RequestInit) => { - if (String(input) !== CLAIM_URL || init?.method !== 'POST') { - return Promise.resolve(new Response('wrong request', { status: 500 })); + if (String(input) !== CLAIM_URL || init?.method !== "POST") { + return Promise.resolve(new Response("wrong request", { status: 500 })); } return Promise.resolve(new Response(ACCESS_URL, { status: 200 })); }) as typeof fetch; const connection = await claimSetupToken(db, TOKEN, fakeFetch); - if (connection.accessUrl !== ACCESS_URL) throw new Error('access URL mismatch'); - if (listConnections(db).length !== 1) throw new Error('connection not stored'); + if (connection.accessUrl !== ACCESS_URL) { + throw new Error("access URL mismatch"); + } + if (listConnections(db).length !== 1) { + throw new Error("connection not stored"); + } db.close(); }); -Deno.test('already-claimed token surfaces a clear error and stores nothing', async () => { +Deno.test("already-claimed token surfaces a clear error and stores nothing", async () => { const db = openDatabase(`${Deno.makeTempDirSync()}/t.db`, MIGRATIONS_DIR); - const fakeFetch = (() => Promise.resolve(new Response('', { status: 403 }))) as typeof fetch; + const fakeFetch = + (() => Promise.resolve(new Response("", { status: 403 }))) as typeof fetch; let caught: unknown; try { await claimSetupToken(db, TOKEN, fakeFetch); @@ -37,23 +43,25 @@ Deno.test('already-claimed token surfaces a clear error and stores nothing', asy caught = err; } if (!(caught instanceof ClaimError) || !caught.alreadyClaimed) { - throw new Error('expected ClaimError with alreadyClaimed=true'); + throw new Error("expected ClaimError with alreadyClaimed=true"); + } + if (listConnections(db).length !== 0) { + throw new Error("connection should not be stored"); } - if (listConnections(db).length !== 0) throw new Error('connection should not be stored'); db.close(); }); -Deno.test('garbage token is rejected before any network call', async () => { +Deno.test("garbage token is rejected before any network call", async () => { const db = openDatabase(`${Deno.makeTempDirSync()}/t.db`, MIGRATIONS_DIR); const explodingFetch = (() => { - throw new Error('network should not be touched'); + throw new Error("network should not be touched"); }) as typeof fetch; let threw = false; try { - await claimSetupToken(db, 'not-base64!!!', explodingFetch); + await claimSetupToken(db, "not-base64!!!", explodingFetch); } catch (err) { threw = err instanceof ClaimError; } - if (!threw) throw new Error('expected ClaimError'); + if (!threw) throw new Error("expected ClaimError"); db.close(); }); diff --git a/src/lib/server/services/connections.ts b/src/lib/server/services/connections.ts index fbba268..4b62b87 100644 --- a/src/lib/server/services/connections.ts +++ b/src/lib/server/services/connections.ts @@ -1,5 +1,5 @@ -import type { DatabaseSync } from 'node:sqlite'; -import { claimAccessUrl, decodeSetupToken } from '../simplefin.ts'; +import type { DatabaseSync } from "node:sqlite"; +import { claimAccessUrl, decodeSetupToken } from "../simplefin.ts"; export interface Connection { id: number; @@ -9,12 +9,12 @@ export interface Connection { export function listConnections(db: DatabaseSync): Connection[] { const rows = db - .prepare('SELECT id, access_url, claimed_at FROM connections ORDER BY id') + .prepare("SELECT id, access_url, claimed_at FROM connections ORDER BY id") .all() as Record[]; return rows.map((r) => ({ id: r.id as number, accessUrl: r.access_url as string, - claimedAt: r.claimed_at as string + claimedAt: r.claimed_at as string, })); } @@ -22,13 +22,13 @@ export function listConnections(db: DatabaseSync): Connection[] { export async function claimSetupToken( db: DatabaseSync, token: string, - fetchFn: typeof fetch = fetch + fetchFn: typeof fetch = fetch, ): Promise { const claimUrl = decodeSetupToken(token); const accessUrl = await claimAccessUrl(claimUrl, fetchFn); const claimedAt = new Date().toISOString(); const result = db - .prepare('INSERT INTO connections (access_url, claimed_at) VALUES (?, ?)') + .prepare("INSERT INTO connections (access_url, claimed_at) VALUES (?, ?)") .run(accessUrl, claimedAt); return { id: Number(result.lastInsertRowid), accessUrl, claimedAt }; } diff --git a/src/lib/server/services/csv-import.test.ts b/src/lib/server/services/csv-import.test.ts index 871d57d..acad1d0 100644 --- a/src/lib/server/services/csv-import.test.ts +++ b/src/lib/server/services/csv-import.test.ts @@ -1,14 +1,14 @@ /// import { + type CsvMapping, detectDateFormat, detectMapping, normalizeCsv, normalizeCsvAmount, parseCsv, parseCsvDate, - type CsvMapping -} from './csv-import.ts'; -import { parseAmountToCents } from './normalize.ts'; +} from "./csv-import.ts"; +import { parseAmountToCents } from "./normalize.ts"; const SIGNED_CSV = `Date,Description,Amount 2026-07-02,Trader Joe's,-88.14 @@ -17,110 +17,138 @@ const SIGNED_CSV = `Date,Description,Amount function signedMapping(overrides: Partial = {}): CsvMapping { return { - date: 'Date', - dateFormat: 'iso', - amountMode: 'signed', - amount: 'Amount', - description: 'Description', - ...overrides + date: "Date", + dateFormat: "iso", + amountMode: "signed", + amount: "Amount", + description: "Description", + ...overrides, }; } -Deno.test('parseCsv strips a BOM and tolerates CRLF', () => { - const parsed = parseCsv('Date,Description,Amount\r\n2026-07-02,Coffee,-4.75\r\n'); - if (parsed.headers[0] !== 'Date') throw new Error(`BOM leaked: ${JSON.stringify(parsed.headers[0])}`); - if (parsed.rows.length !== 1) throw new Error(`expected 1 row, got ${parsed.rows.length}`); +Deno.test("parseCsv strips a BOM and tolerates CRLF", () => { + const parsed = parseCsv( + "Date,Description,Amount\r\n2026-07-02,Coffee,-4.75\r\n", + ); + if (parsed.headers[0] !== "Date") { + throw new Error(`BOM leaked: ${JSON.stringify(parsed.headers[0])}`); + } + if (parsed.rows.length !== 1) { + throw new Error(`expected 1 row, got ${parsed.rows.length}`); + } }); -Deno.test('parseCsv handles quoted delimiters and embedded newlines', () => { - const parsed = parseCsv('Date,Description,Amount\n2026-07-02,"Shop, Inc.\nStore #4",-10.00\n'); - if (parsed.rows[0][1] !== 'Shop, Inc.\nStore #4') { +Deno.test("parseCsv handles quoted delimiters and embedded newlines", () => { + const parsed = parseCsv( + 'Date,Description,Amount\n2026-07-02,"Shop, Inc.\nStore #4",-10.00\n', + ); + if (parsed.rows[0][1] !== "Shop, Inc.\nStore #4") { throw new Error(`quoting mishandled: ${JSON.stringify(parsed.rows[0][1])}`); } - if (parsed.rows[0][2] !== '-10.00') throw new Error('column alignment broken by quoting'); + if (parsed.rows[0][2] !== "-10.00") { + throw new Error("column alignment broken by quoting"); + } }); -Deno.test('parseCsv rejects an empty file', () => { +Deno.test("parseCsv rejects an empty file", () => { let threw = false; try { - parseCsv('\n\n'); + parseCsv("\n\n"); } catch { threw = true; } - if (!threw) throw new Error('expected an empty file to throw'); + if (!threw) throw new Error("expected an empty file to throw"); }); -Deno.test('normalizeCsvAmount handles bank negative conventions', () => { +Deno.test("normalizeCsvAmount handles bank negative conventions", () => { // Thousands separators pass through by design — parseAmountToCents strips them. const cases: [string, string][] = [ - ['(12.34)', '-12.34'], - ['12.34-', '-12.34'], - ['-12.34', '-12.34'], - ['$1,234.56', '1,234.56'], - ['($1,234.56)', '-1,234.56'], - ['12.34', '12.34'], - ['', ''], - [' ', ''] + ["(12.34)", "-12.34"], + ["12.34-", "-12.34"], + ["-12.34", "-12.34"], + ["$1,234.56", "1,234.56"], + ["($1,234.56)", "-1,234.56"], + ["12.34", "12.34"], + ["", ""], + [" ", ""], ]; for (const [input, expected] of cases) { const actual = normalizeCsvAmount(input); if (actual !== expected) { - throw new Error(`${JSON.stringify(input)} -> ${JSON.stringify(actual)}, want ${expected}`); + throw new Error( + `${JSON.stringify(input)} -> ${ + JSON.stringify(actual) + }, want ${expected}`, + ); } } }); -Deno.test('normalizeCsvAmount output is accepted by parseAmountToCents', () => { +Deno.test("normalizeCsvAmount output is accepted by parseAmountToCents", () => { // The handoff is the point: this pre-pass exists so parseAmountToCents can stay // strict for the sync path. const cases: [string, number][] = [ - ['(12.34)', -1234], - ['12.34-', -1234], - ['$1,234.56', 123456], - ['($1,234.56)', -123456], - ['12.34', 1234] + ["(12.34)", -1234], + ["12.34-", -1234], + ["$1,234.56", 123456], + ["($1,234.56)", -123456], + ["12.34", 1234], ]; for (const [input, expected] of cases) { const actual = parseAmountToCents(normalizeCsvAmount(input)); if (actual !== expected) { - throw new Error(`${JSON.stringify(input)} -> ${actual} cents, want ${expected}`); + throw new Error( + `${JSON.stringify(input)} -> ${actual} cents, want ${expected}`, + ); } } }); -Deno.test('parenthesized negative reaches integer cents', () => { +Deno.test("parenthesized negative reaches integer cents", () => { const { candidates, errors } = normalizeCsv( - 'Date,Description,Amount\n2026-07-02,Coffee,(12.34)\n', + "Date,Description,Amount\n2026-07-02,Coffee,(12.34)\n", signedMapping(), - 'acct-1' + "acct-1", ); - if (errors.length) throw new Error(errors.join('; ')); + if (errors.length) throw new Error(errors.join("; ")); if (candidates[0].amountCents !== -1234) { throw new Error(`got ${candidates[0].amountCents}, want -1234`); } }); -Deno.test('detectDateFormat reads the order off the data', () => { - if (detectDateFormat(['2026-07-02']) !== 'iso') throw new Error('iso not detected'); +Deno.test("detectDateFormat reads the order off the data", () => { + if (detectDateFormat(["2026-07-02"]) !== "iso") { + throw new Error("iso not detected"); + } // 13 can only be a day, so day comes first. - if (detectDateFormat(['03/04/2026', '13/04/2026']) !== 'dmy') throw new Error('dmy not detected'); - if (detectDateFormat(['03/04/2026', '04/13/2026']) !== 'mdy') throw new Error('mdy not detected'); + if (detectDateFormat(["03/04/2026", "13/04/2026"]) !== "dmy") { + throw new Error("dmy not detected"); + } + if (detectDateFormat(["03/04/2026", "04/13/2026"]) !== "mdy") { + throw new Error("mdy not detected"); + } // Wholly ambiguous input falls back to month-first. - if (detectDateFormat(['03/04/2026']) !== 'mdy') throw new Error('ambiguous should default to mdy'); + if (detectDateFormat(["03/04/2026"]) !== "mdy") { + throw new Error("ambiguous should default to mdy"); + } }); -Deno.test('parseCsvDate respects the mapped order', () => { - const mdy = parseCsvDate('03/04/2026', 'mdy'); - const dmy = parseCsvDate('03/04/2026', 'dmy'); - if (mdy !== Date.UTC(2026, 2, 4, 12) / 1000) throw new Error('mdy parsed wrong'); - if (dmy !== Date.UTC(2026, 3, 3, 12) / 1000) throw new Error('dmy parsed wrong'); +Deno.test("parseCsvDate respects the mapped order", () => { + const mdy = parseCsvDate("03/04/2026", "mdy"); + const dmy = parseCsvDate("03/04/2026", "dmy"); + if (mdy !== Date.UTC(2026, 2, 4, 12) / 1000) { + throw new Error("mdy parsed wrong"); + } + if (dmy !== Date.UTC(2026, 3, 3, 12) / 1000) { + throw new Error("dmy parsed wrong"); + } // ISO is unambiguous and ignores the declared order. - if (parseCsvDate('2026-07-02', 'dmy') !== Date.UTC(2026, 6, 2, 12) / 1000) { - throw new Error('iso should win regardless of format'); + if (parseCsvDate("2026-07-02", "dmy") !== Date.UTC(2026, 6, 2, 12) / 1000) { + throw new Error("iso should win regardless of format"); } }); -Deno.test('a date-only value survives local rendering from UTC-12 to UTC+11', () => { +Deno.test("a date-only value survives local rendering from UTC-12 to UTC+11", () => { // The reason for anchoring at noon: formatDay renders in local time, so a // UTC-midnight stamp would read as the previous day across the Americas, and a // month's first day would display in the month before the one it reports under. @@ -129,20 +157,22 @@ Deno.test('a date-only value survives local rendering from UTC-12 to UTC+11', () // (New Zealand, Kiribati) noon crosses into the next day. Documented, not fixed // — the honest fix is rendering date-only rows in UTC, which is worth doing only // if that ever matters. - const posted = parseCsvDate('2026-07-01', 'iso'); + const posted = parseCsvDate("2026-07-01", "iso"); for (let offset = -12; offset <= 11; offset++) { const local = new Date((posted + offset * 3600) * 1000); if (local.getUTCDate() !== 1 || local.getUTCMonth() !== 6) { - throw new Error(`UTC${offset >= 0 ? '+' : ''}${offset} shifts the calendar day`); + throw new Error( + `UTC${offset >= 0 ? "+" : ""}${offset} shifts the calendar day`, + ); } } }); -Deno.test('parseCsvDate rejects nonsense', () => { - for (const bad of ['', 'not a date', '99/99/2026']) { +Deno.test("parseCsvDate rejects nonsense", () => { + for (const bad of ["", "not a date", "99/99/2026"]) { let threw = false; try { - parseCsvDate(bad, 'mdy'); + parseCsvDate(bad, "mdy"); } catch { threw = true; } @@ -150,163 +180,191 @@ Deno.test('parseCsvDate rejects nonsense', () => { } }); -Deno.test('detectMapping finds signed-column headers', () => { +Deno.test("detectMapping finds signed-column headers", () => { const mapping = detectMapping(parseCsv(SIGNED_CSV)); - if (!mapping) throw new Error('expected a mapping'); - if (mapping.amountMode !== 'signed' || mapping.amount !== 'Amount') { + if (!mapping) throw new Error("expected a mapping"); + if (mapping.amountMode !== "signed" || mapping.amount !== "Amount") { throw new Error(`wrong amount mapping: ${JSON.stringify(mapping)}`); } - if (mapping.date !== 'Date' || mapping.description !== 'Description') { + if (mapping.date !== "Date" || mapping.description !== "Description") { throw new Error(`wrong column mapping: ${JSON.stringify(mapping)}`); } - if (mapping.dateFormat !== 'iso') throw new Error('expected iso'); + if (mapping.dateFormat !== "iso") throw new Error("expected iso"); }); -Deno.test('detectMapping finds a debit/credit pair', () => { +Deno.test("detectMapping finds a debit/credit pair", () => { const mapping = detectMapping( - parseCsv('Date,Description,Debit,Credit\n07/02/2026,Coffee,4.75,\n') + parseCsv("Date,Description,Debit,Credit\n07/02/2026,Coffee,4.75,\n"), ); - if (!mapping) throw new Error('expected a mapping'); - if (mapping.amountMode !== 'debit-credit') throw new Error('expected debit-credit mode'); - if (mapping.debit !== 'Debit' || mapping.credit !== 'Credit') { + if (!mapping) throw new Error("expected a mapping"); + if (mapping.amountMode !== "debit-credit") { + throw new Error("expected debit-credit mode"); + } + if (mapping.debit !== "Debit" || mapping.credit !== "Credit") { throw new Error(`wrong pair: ${JSON.stringify(mapping)}`); } }); -Deno.test('detectMapping gives up rather than guessing', () => { - if (detectMapping(parseCsv('Foo,Bar,Baz\n1,2,3\n')) !== null) { - throw new Error('expected null for unrecognizable headers'); +Deno.test("detectMapping gives up rather than guessing", () => { + if (detectMapping(parseCsv("Foo,Bar,Baz\n1,2,3\n")) !== null) { + throw new Error("expected null for unrecognizable headers"); } }); -Deno.test('debit and credit columns resolve to one signed amount', () => { +Deno.test("debit and credit columns resolve to one signed amount", () => { const mapping: CsvMapping = { - date: 'Date', - dateFormat: 'mdy', - amountMode: 'debit-credit', - debit: 'Debit', - credit: 'Credit', - description: 'Description' + date: "Date", + dateFormat: "mdy", + amountMode: "debit-credit", + debit: "Debit", + credit: "Credit", + description: "Description", }; const { candidates, errors } = normalizeCsv( - 'Date,Description,Debit,Credit\n07/02/2026,Coffee,4.75,\n07/03/2026,Refund,,20.00\n', + "Date,Description,Debit,Credit\n07/02/2026,Coffee,4.75,\n07/03/2026,Refund,,20.00\n", mapping, - 'acct-1' + "acct-1", ); - if (errors.length) throw new Error(errors.join('; ')); - if (candidates[0].amountCents !== -475) throw new Error(`debit -> ${candidates[0].amountCents}`); - if (candidates[1].amountCents !== 2000) throw new Error(`credit -> ${candidates[1].amountCents}`); + if (errors.length) throw new Error(errors.join("; ")); + if (candidates[0].amountCents !== -475) { + throw new Error(`debit -> ${candidates[0].amountCents}`); + } + if (candidates[1].amountCents !== 2000) { + throw new Error(`credit -> ${candidates[1].amountCents}`); + } }); -Deno.test('a bad row is reported without sinking the file', () => { +Deno.test("a bad row is reported without sinking the file", () => { const { candidates, errors } = normalizeCsv( - 'Date,Description,Amount\n2026-07-02,Coffee,-4.75\nnot-a-date,Broken,-1.00\n', + "Date,Description,Amount\n2026-07-02,Coffee,-4.75\nnot-a-date,Broken,-1.00\n", signedMapping(), - 'acct-1' + "acct-1", ); - if (candidates.length !== 1) throw new Error(`expected 1 good row, got ${candidates.length}`); - if (errors.length !== 1 || !errors[0].includes('Line 3')) { + if (candidates.length !== 1) { + throw new Error(`expected 1 good row, got ${candidates.length}`); + } + if (errors.length !== 1 || !errors[0].includes("Line 3")) { throw new Error(`expected a line-3 error, got ${JSON.stringify(errors)}`); } }); -Deno.test('imported rows carry posted and never pend', () => { - const { candidates } = normalizeCsv(SIGNED_CSV, signedMapping(), 'acct-1'); - if (candidates[0].posted !== Date.UTC(2026, 6, 2, 12) / 1000) throw new Error('posted not set'); - if (candidates[0].transactedAt !== null) throw new Error('transactedAt should be null'); +Deno.test("imported rows carry posted and never pend", () => { + const { candidates } = normalizeCsv(SIGNED_CSV, signedMapping(), "acct-1"); + if (candidates[0].posted !== Date.UTC(2026, 6, 2, 12) / 1000) { + throw new Error("posted not set"); + } + if (candidates[0].transactedAt !== null) { + throw new Error("transactedAt should be null"); + } }); -Deno.test('a distinct transaction-date column maps separately', () => { +Deno.test("a distinct transaction-date column maps separately", () => { const { candidates, errors } = normalizeCsv( - 'Post Date,Transaction Date,Description,Amount\n2026-07-04,2026-07-02,Coffee,-4.75\n', - signedMapping({ date: 'Post Date', transactedDate: 'Transaction Date' }), - 'acct-1' + "Post Date,Transaction Date,Description,Amount\n2026-07-04,2026-07-02,Coffee,-4.75\n", + signedMapping({ date: "Post Date", transactedDate: "Transaction Date" }), + "acct-1", ); - if (errors.length) throw new Error(errors.join('; ')); - if (candidates[0].posted !== Date.UTC(2026, 6, 4, 12) / 1000) throw new Error('wrong posted'); + if (errors.length) throw new Error(errors.join("; ")); + if (candidates[0].posted !== Date.UTC(2026, 6, 4, 12) / 1000) { + throw new Error("wrong posted"); + } if (candidates[0].transactedAt !== Date.UTC(2026, 6, 2, 12) / 1000) { - throw new Error('wrong transactedAt'); + throw new Error("wrong transactedAt"); } }); -Deno.test('identity is stable across runs', () => { - const a = normalizeCsv(SIGNED_CSV, signedMapping(), 'acct-1'); - const b = normalizeCsv(SIGNED_CSV, signedMapping(), 'acct-1'); - const idsA = a.candidates.map((c) => c.syntheticId).join(','); - const idsB = b.candidates.map((c) => c.syntheticId).join(','); - if (idsA !== idsB) throw new Error('ids must be deterministic'); +Deno.test("identity is stable across runs", () => { + const a = normalizeCsv(SIGNED_CSV, signedMapping(), "acct-1"); + const b = normalizeCsv(SIGNED_CSV, signedMapping(), "acct-1"); + const idsA = a.candidates.map((c) => c.syntheticId).join(","); + const idsB = b.candidates.map((c) => c.syntheticId).join(","); + if (idsA !== idsB) throw new Error("ids must be deterministic"); }); -Deno.test('identity is namespaced and account-scoped', () => { - const { candidates } = normalizeCsv(SIGNED_CSV, signedMapping(), 'acct-1'); - if (!candidates[0].syntheticId.startsWith('csv:')) throw new Error('missing csv: namespace'); +Deno.test("identity is namespaced and account-scoped", () => { + const { candidates } = normalizeCsv(SIGNED_CSV, signedMapping(), "acct-1"); + if (!candidates[0].syntheticId.startsWith("csv:")) { + throw new Error("missing csv: namespace"); + } - const other = normalizeCsv(SIGNED_CSV, signedMapping(), 'acct-2'); + const other = normalizeCsv(SIGNED_CSV, signedMapping(), "acct-2"); if (candidates[0].syntheticId === other.candidates[0].syntheticId) { - throw new Error('the same row in a different account must not share an id'); + throw new Error("the same row in a different account must not share an id"); } }); -Deno.test('two genuinely identical rows stay distinct', () => { +Deno.test("two genuinely identical rows stay distinct", () => { const { candidates } = normalizeCsv( - 'Date,Description,Amount\n2026-07-02,Coffee,-4.75\n2026-07-02,Coffee,-4.75\n', + "Date,Description,Amount\n2026-07-02,Coffee,-4.75\n2026-07-02,Coffee,-4.75\n", signedMapping(), - 'acct-1' + "acct-1", ); - if (candidates.length !== 2) throw new Error(`expected 2 rows, got ${candidates.length}`); + if (candidates.length !== 2) { + throw new Error(`expected 2 rows, got ${candidates.length}`); + } if (candidates[0].syntheticId === candidates[1].syntheticId) { - throw new Error('identical rows must get distinct ids via the occurrence index'); + throw new Error( + "identical rows must get distinct ids via the occurrence index", + ); } }); -Deno.test('cosmetic description re-rendering does not mint a new identity', () => { - const a = normalizeCsv('Date,Description,Amount\n2026-07-02,Trader Joe\'s,-88.14\n', signedMapping(), 'acct-1'); +Deno.test("cosmetic description re-rendering does not mint a new identity", () => { + const a = normalizeCsv( + "Date,Description,Amount\n2026-07-02,Trader Joe's,-88.14\n", + signedMapping(), + "acct-1", + ); const b = normalizeCsv( - 'Date,Description,Amount\n2026-07-02, TRADER JOE\'S ,-88.14\n', + "Date,Description,Amount\n2026-07-02, TRADER JOE'S ,-88.14\n", signedMapping(), - 'acct-1' + "acct-1", ); if (a.candidates[0].syntheticId !== b.candidates[0].syntheticId) { - throw new Error('folded description should yield the same id'); + throw new Error("folded description should yield the same id"); } }); -Deno.test('a reordered group re-derives the same id set', () => { +Deno.test("a reordered group re-derives the same id set", () => { // Rows within a content group are interchangeable, so export order must not // change the set of ids — this is what makes an overlapping re-import a no-op. const first = normalizeCsv( - 'Date,Description,Amount\n2026-07-02,Coffee,-4.75\n2026-07-02,Bagel,-3.00\n2026-07-02,Coffee,-4.75\n', + "Date,Description,Amount\n2026-07-02,Coffee,-4.75\n2026-07-02,Bagel,-3.00\n2026-07-02,Coffee,-4.75\n", signedMapping(), - 'acct-1' + "acct-1", ); const reordered = normalizeCsv( - 'Date,Description,Amount\n2026-07-02,Coffee,-4.75\n2026-07-02,Coffee,-4.75\n2026-07-02,Bagel,-3.00\n', + "Date,Description,Amount\n2026-07-02,Coffee,-4.75\n2026-07-02,Coffee,-4.75\n2026-07-02,Bagel,-3.00\n", signedMapping(), - 'acct-1' + "acct-1", ); const setA = new Set(first.candidates.map((c) => c.syntheticId)); const setB = new Set(reordered.candidates.map((c) => c.syntheticId)); if (setA.size !== setB.size || [...setA].some((id) => !setB.has(id))) { - throw new Error('id set must be independent of row order within a group'); + throw new Error("id set must be independent of row order within a group"); } }); -Deno.test('a superset re-export only adds genuinely new ids', () => { +Deno.test("a superset re-export only adds genuinely new ids", () => { const first = normalizeCsv( - 'Date,Description,Amount\n2026-07-02,Coffee,-4.75\n2026-07-02,Coffee,-4.75\n', + "Date,Description,Amount\n2026-07-02,Coffee,-4.75\n2026-07-02,Coffee,-4.75\n", signedMapping(), - 'acct-1' + "acct-1", ); const superset = normalizeCsv( - 'Date,Description,Amount\n2026-07-02,Coffee,-4.75\n2026-07-02,Coffee,-4.75\n2026-07-02,Coffee,-4.75\n', + "Date,Description,Amount\n2026-07-02,Coffee,-4.75\n2026-07-02,Coffee,-4.75\n2026-07-02,Coffee,-4.75\n", signedMapping(), - 'acct-1' + "acct-1", ); const before = new Set(first.candidates.map((c) => c.syntheticId)); const after = superset.candidates.map((c) => c.syntheticId); const added = after.filter((id) => !before.has(id)); - if (added.length !== 1) throw new Error(`expected exactly 1 new id, got ${added.length}`); + if (added.length !== 1) { + throw new Error(`expected exactly 1 new id, got ${added.length}`); + } for (const id of before) { - if (!after.includes(id)) throw new Error('previously ingested ids must survive a superset'); + if (!after.includes(id)) { + throw new Error("previously ingested ids must survive a superset"); + } } }); diff --git a/src/lib/server/services/csv-import.ts b/src/lib/server/services/csv-import.ts index 419adeb..b76aacc 100644 --- a/src/lib/server/services/csv-import.ts +++ b/src/lib/server/services/csv-import.ts @@ -4,18 +4,18 @@ // // CSV import is an ADDITIVE writer. Nothing here reconciles, sweeps, or mutates. -import { parse } from 'csv-parse/sync'; -import { createHash } from 'node:crypto'; -import { parseAmountToCents } from './normalize.ts'; +import { parse } from "csv-parse/sync"; +import { createHash } from "node:crypto"; +import { parseAmountToCents } from "./normalize.ts"; /** * `03/04/2026` is March 4th at one bank and April 3rd at another, and the file * never says which. The format is part of the mapping so the user's answer is * archived and the import replays deterministically. */ -export type CsvDateFormat = 'iso' | 'mdy' | 'dmy'; +export type CsvDateFormat = "iso" | "mdy" | "dmy"; -export type CsvAmountMode = 'signed' | 'debit-credit'; +export type CsvAmountMode = "signed" | "debit-credit"; export interface CsvMapping { date: string; @@ -67,20 +67,22 @@ export function parseCsv(payloadText: string): ParsedCsv { bom: true, skip_empty_lines: true, relax_column_count: true, - relax_quotes: true + relax_quotes: true, }) as string[][]; - const nonEmpty = rows.filter((row) => row.some((cell) => cell.trim() !== '')); - if (nonEmpty.length === 0) throw new Error('The file has no rows.'); + const nonEmpty = rows.filter((row) => row.some((cell) => cell.trim() !== "")); + if (nonEmpty.length === 0) throw new Error("The file has no rows."); const [headers, ...data] = nonEmpty; return { headers: headers.map((h) => h.trim()), rows: data }; } -const DATE_RE = /^(date|posted|post date|posting date|transaction date|trans date)$/i; +const DATE_RE = + /^(date|posted|post date|posting date|transaction date|trans date)$/i; const TXN_DATE_RE = /^(transaction date|trans date)$/i; const AMOUNT_RE = /^(amount|value|transaction amount)$/i; const DEBIT_RE = /^(debit|withdrawal|withdrawals|money out|paid out)$/i; const CREDIT_RE = /^(credit|deposit|deposits|money in|paid in)$/i; -const DESC_RE = /^(description|details|name|merchant|payee|narrative|transaction)$/i; +const DESC_RE = + /^(description|details|name|merchant|payee|narrative|transaction)$/i; const PAYEE_RE = /^(payee|merchant|name)$/i; const MEMO_RE = /^(memo|note|notes|reference)$/i; @@ -104,10 +106,10 @@ export function detectDateFormat(values: string[]): CsvDateFormat { } const match = text.match(/^(\d{1,2})[/.-](\d{1,2})[/.-](\d{2,4})$/); if (!match) continue; - if (Number(match[1]) > 12) return 'dmy'; - if (Number(match[2]) > 12) return 'mdy'; + if (Number(match[1]) > 12) return "dmy"; + if (Number(match[2]) > 12) return "mdy"; } - return sawIso ? 'iso' : 'mdy'; + return sawIso ? "iso" : "mdy"; } /** Best-guess column assignment from a header row. Always user-confirmable. */ @@ -122,7 +124,7 @@ export function detectMapping(parsed: ParsedCsv): CsvMapping | null { const credit = findHeader(headers, CREDIT_RE); const dateIndex = headers.indexOf(date); - const dateFormat = detectDateFormat(rows.map((r) => r[dateIndex] ?? '')); + const dateFormat = detectDateFormat(rows.map((r) => r[dateIndex] ?? "")); // A distinct transaction-date column only counts when it isn't the one already // serving as the post date. @@ -137,10 +139,12 @@ export function detectMapping(parsed: ParsedCsv): CsvMapping | null { dateFormat, description, payee: payee && payee !== description ? payee : null, - memo: findHeader(headers, MEMO_RE) + memo: findHeader(headers, MEMO_RE), }; - if (amount) return { ...base, amountMode: 'signed', amount }; - if (debit || credit) return { ...base, amountMode: 'debit-credit', debit, credit }; + if (amount) return { ...base, amountMode: "signed", amount }; + if (debit || credit) { + return { ...base, amountMode: "debit-credit", debit, credit }; + } return null; } @@ -152,8 +156,8 @@ export function detectMapping(parsed: ParsedCsv): CsvMapping | null { * Returns '' for a blank cell. */ export function normalizeCsvAmount(raw: string): string { - let text = String(raw ?? '').trim(); - if (!text) return ''; + let text = String(raw ?? "").trim(); + if (!text) return ""; let negative = false; const parenthesized = text.match(/^\((.*)\)$/); @@ -161,18 +165,18 @@ export function normalizeCsvAmount(raw: string): string { negative = true; text = parenthesized[1].trim(); } - if (text.endsWith('-')) { + if (text.endsWith("-")) { negative = !negative; text = text.slice(0, -1).trim(); } - if (text.startsWith('-')) { + if (text.startsWith("-")) { negative = !negative; text = text.slice(1).trim(); } // Drop currency symbols and stray spaces; parseAmountToCents handles commas. - text = text.replace(/[^\d.,]/g, ''); - if (!text) return ''; + text = text.replace(/[^\d.,]/g, ""); + if (!text) return ""; return negative ? `-${text}` : text; } @@ -187,7 +191,7 @@ export function normalizeCsvAmount(raw: string): string { * right UTC month. `formatMonth` already anchors to the 15th for the same reason. */ export function parseCsvDate(raw: string, format: CsvDateFormat): number { - const text = String(raw ?? '').trim(); + const text = String(raw ?? "").trim(); let year: number, month: number, day: number; const iso = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/); @@ -200,7 +204,7 @@ export function parseCsvDate(raw: string, format: CsvDateFormat): number { const second = Number(parts[2]); year = Number(parts[3]); if (year < 100) year += 2000; - if (format === 'dmy') { + if (format === "dmy") { day = first; month = second; } else { @@ -222,26 +226,41 @@ export function parseCsvDate(raw: string, format: CsvDateFormat): number { * new rows fall outside it. Description is folded (trimmed, whitespace-collapsed, * lowercased) so cosmetic re-rendering doesn't mint a new identity. */ -function contentKey(accountId: string, posted: number, amountCents: number, description: string) { - const folded = description.trim().replace(/\s+/g, ' ').toLowerCase(); - return [accountId, String(posted), String(amountCents), folded].join(''); +function contentKey( + accountId: string, + posted: number, + amountCents: number, + description: string, +) { + const folded = description.trim().replace(/\s+/g, " ").toLowerCase(); + return [accountId, String(posted), String(amountCents), folded].join(""); } function syntheticId(key: string, occurrence: number): string { - const digest = createHash('sha256').update(`${key}${occurrence}`).digest('hex'); + const digest = createHash("sha256").update(`${key}${occurrence}`).digest( + "hex", + ); return `csv:${digest.slice(0, 32)}`; } -function cell(row: string[], headers: string[], name: string | null | undefined): string { - if (!name) return ''; +function cell( + row: string[], + headers: string[], + name: string | null | undefined, +): string { + if (!name) return ""; const index = headers.indexOf(name); - return index === -1 ? '' : (row[index] ?? ''); + return index === -1 ? "" : (row[index] ?? ""); } -function resolveAmountCents(row: string[], headers: string[], mapping: CsvMapping): number { - if (mapping.amountMode === 'signed') { +function resolveAmountCents( + row: string[], + headers: string[], + mapping: CsvMapping, +): number { + if (mapping.amountMode === "signed") { const normalized = normalizeCsvAmount(cell(row, headers, mapping.amount)); - if (!normalized) throw new Error('amount is blank'); + if (!normalized) throw new Error("amount is blank"); return parseAmountToCents(normalized); } @@ -250,10 +269,12 @@ function resolveAmountCents(row: string[], headers: string[], mapping: CsvMappin const debit = debitText ? parseAmountToCents(debitText) : 0; const credit = creditText ? parseAmountToCents(creditText) : 0; - if (debit !== 0 && credit !== 0) throw new Error('both debit and credit are populated'); + if (debit !== 0 && credit !== 0) { + throw new Error("both debit and credit are populated"); + } if (debit !== 0) return -Math.abs(debit); if (credit !== 0) return Math.abs(credit); - throw new Error('neither debit nor credit is populated'); + throw new Error("neither debit nor credit is populated"); } /** @@ -264,7 +285,7 @@ function resolveAmountCents(row: string[], headers: string[], mapping: CsvMappin export function normalizeCsv( payloadText: string, mapping: CsvMapping, - accountId: string + accountId: string, ): NormalizedCsv { const { headers, rows } = parseCsv(payloadText); const candidates: CsvCandidate[] = []; @@ -274,10 +295,13 @@ export function normalizeCsv( rows.forEach((row, index) => { const line = index + 2; // 1-based, and the header occupies line 1 try { - const posted = parseCsvDate(cell(row, headers, mapping.date), mapping.dateFormat); + const posted = parseCsvDate( + cell(row, headers, mapping.date), + mapping.dateFormat, + ); const amountCents = resolveAmountCents(row, headers, mapping); const description = cell(row, headers, mapping.description).trim(); - if (!description) throw new Error('description is blank'); + if (!description) throw new Error("description is blank"); const transactedRaw = cell(row, headers, mapping.transactedDate).trim(); const transactedAt = transactedRaw @@ -298,10 +322,12 @@ export function normalizeCsv( amountCents, description, payee: payee || null, - memo: memo || null + memo: memo || null, }); } catch (err) { - errors.push(`Line ${line}: ${err instanceof Error ? err.message : String(err)}`); + errors.push( + `Line ${line}: ${err instanceof Error ? err.message : String(err)}`, + ); } }); diff --git a/src/lib/server/services/imports.test.ts b/src/lib/server/services/imports.test.ts index d9945f8..0d970de 100644 --- a/src/lib/server/services/imports.test.ts +++ b/src/lib/server/services/imports.test.ts @@ -1,5 +1,5 @@ /// -import { openDatabase } from '../db.ts'; +import { openDatabase } from "../db.ts"; import { commitImport, createDraftImport, @@ -8,18 +8,19 @@ import { previewImport, saveDecisions, saveMapping, - undoImport -} from './imports.ts'; -import { normalizeCsv, type CsvMapping } from './csv-import.ts'; -import { createRule } from './rules.ts'; -import { categorizeManually } from './categorization.ts'; -import { monthlyReport, netWorthSeries } from './reports.ts'; -import type { DatabaseSync } from 'node:sqlite'; - -const MIGRATIONS_DIR = new URL('../../../../migrations', import.meta.url).pathname.replace( - /^\/([A-Za-z]:)/, - '$1' -); + undoImport, +} from "./imports.ts"; +import { type CsvMapping, normalizeCsv } from "./csv-import.ts"; +import { createRule } from "./rules.ts"; +import { categorizeManually } from "./categorization.ts"; +import { monthlyReport, netWorthSeries } from "./reports.ts"; +import type { DatabaseSync } from "node:sqlite"; + +const MIGRATIONS_DIR = new URL("../../../../migrations", import.meta.url) + .pathname.replace( + /^\/([A-Za-z]:)/, + "$1", + ); /** The synced row sits just after UTC midnight on purpose: a CSV row is anchored at * noon, so a naive ±86400s window would be hour-sensitive. The duplicate check @@ -30,194 +31,244 @@ const JULY_02_NOON = Math.floor(Date.UTC(2026, 6, 2, 12) / 1000); const JULY_03_NOON = Math.floor(Date.UTC(2026, 6, 3, 12) / 1000); const MAPPING: CsvMapping = { - date: 'Date', - dateFormat: 'iso', - amountMode: 'signed', - amount: 'Amount', - description: 'Description' + date: "Date", + dateFormat: "iso", + amountMode: "signed", + amount: "Amount", + description: "Description", }; /** The account holds a synced posted row, a synced pending row, and a category. */ function testDb(): DatabaseSync { const db = openDatabase(`${Deno.makeTempDirSync()}/t.db`, MIGRATIONS_DIR); const now = new Date().toISOString(); - db.prepare("INSERT INTO users (did, handle, created_at) VALUES ('did:plc:test', 'tester', ?)").run( - now + db.prepare( + "INSERT INTO users (did, handle, created_at) VALUES ('did:plc:test', 'tester', ?)", + ).run( + now, ); - db.prepare("INSERT INTO connections (access_url, claimed_at) VALUES ('https://x', ?)").run(now); - for (const [id, state] of [ - ['chk', 'ACTIVE'], - ['ghost', 'HIDDEN'] - ]) { + db.prepare( + "INSERT INTO connections (access_url, claimed_at) VALUES ('https://x', ?)", + ).run(now); + for ( + const [id, state] of [ + ["chk", "ACTIVE"], + ["ghost", "HIDDEN"], + ] + ) { db.prepare( `INSERT INTO accounts (id, connection_id, name, currency, state, last_successful_data_at, created_at) - VALUES (?, 1, ?, 'USD', ?, ?, ?)` + VALUES (?, 1, ?, 'USD', ?, ?, ?)`, ).run(id, id, state, now, now); } - db.prepare("INSERT INTO categories (name, kind, created_at) VALUES ('Dining','expense',?)").run( - now + db.prepare( + "INSERT INTO categories (name, kind, created_at) VALUES ('Dining','expense',?)", + ).run( + now, ); const insert = db.prepare( `INSERT INTO transactions (account_id, sfin_id, posted, amount_cents, description, pending, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?)`, ); // The bank's own rendering of the same July 2 purchase the CSV also contains. - insert.run('chk', 'sfin-amazon', JULY_02_EARLY, -5231, 'AMZN Mktp US*2K4LM9QR3', 0, now); - insert.run('chk', 'sfin-pending', null, -500, 'PENDING COFFEE', 1, now); + insert.run( + "chk", + "sfin-amazon", + JULY_02_EARLY, + -5231, + "AMZN Mktp US*2K4LM9QR3", + 0, + now, + ); + insert.run("chk", "sfin-pending", null, -500, "PENDING COFFEE", 1, now); return db; } -function draft(db: DatabaseSync, csv: string, accountId = 'chk'): number { - const id = createDraftImport(db, accountId, 'bank.csv', csv); +function draft(db: DatabaseSync, csv: string, accountId = "chk"): number { + const id = createDraftImport(db, accountId, "bank.csv", csv); saveMapping(db, id, MAPPING); return id; } -Deno.test('createDraftImport archives bytes verbatim before parsing', () => { +Deno.test("createDraftImport archives bytes verbatim before parsing", () => { const db = testDb(); // Garbage that could never parse still has to survive the upload. const raw = 'not,really\nvalid ""csv'; - const id = createDraftImport(db, 'chk', 'junk.csv', raw); - const row = db.prepare('SELECT payload, status FROM imports WHERE id = ?').get(id) as { - payload: string; - status: string; - }; - if (row.payload !== raw) throw new Error('payload was not archived verbatim'); - if (row.status !== 'draft') throw new Error(`expected draft, got ${row.status}`); + const id = createDraftImport(db, "chk", "junk.csv", raw); + const row = db.prepare("SELECT payload, status FROM imports WHERE id = ?") + .get(id) as { + payload: string; + status: string; + }; + if (row.payload !== raw) throw new Error("payload was not archived verbatim"); + if (row.status !== "draft") { + throw new Error(`expected draft, got ${row.status}`); + } db.close(); }); -Deno.test('a hidden account cannot be an import target', () => { +Deno.test("a hidden account cannot be an import target", () => { const db = testDb(); let threw = false; try { - createDraftImport(db, 'ghost', 'x.csv', 'Date,Description,Amount\n'); + createDraftImport(db, "ghost", "x.csv", "Date,Description,Amount\n"); } catch { threw = true; } - if (!threw) throw new Error('expected hidden account to be rejected'); + if (!threw) throw new Error("expected hidden account to be rejected"); db.close(); }); -Deno.test('cross-source duplicate is flagged despite a different description', () => { +Deno.test("cross-source duplicate is flagged despite a different description", () => { const db = testDb(); - const csv = 'Date,Description,Amount\n2026-07-02,Amazon,-52.31\n'; - const { candidates } = normalizeCsv(csv, MAPPING, 'chk'); - const [result] = findPotentialDuplicates(db, 'chk', candidates); - if (result.status !== 'flagged') throw new Error(`expected flagged, got ${result.status}`); - if (result.match?.description !== 'AMZN Mktp US*2K4LM9QR3') throw new Error('wrong match'); - if (result.match?.source !== 'synced') throw new Error('match should be synced'); + const csv = "Date,Description,Amount\n2026-07-02,Amazon,-52.31\n"; + const { candidates } = normalizeCsv(csv, MAPPING, "chk"); + const [result] = findPotentialDuplicates(db, "chk", candidates); + if (result.status !== "flagged") { + throw new Error(`expected flagged, got ${result.status}`); + } + if (result.match?.description !== "AMZN Mktp US*2K4LM9QR3") { + throw new Error("wrong match"); + } + if (result.match?.source !== "synced") { + throw new Error("match should be synced"); + } db.close(); }); -Deno.test('duplicate window spans one calendar day, not 24 hours', () => { +Deno.test("duplicate window spans one calendar day, not 24 hours", () => { const db = testDb(); // The synced row is at 01:30 UTC on Jul 2; this candidate lands at noon Jul 3. // That is 34.5 hours apart but one calendar day, and must still flag. const near = normalizeCsv( - 'Date,Description,Amount\n2026-07-03,Amazon,-52.31\n', + "Date,Description,Amount\n2026-07-03,Amazon,-52.31\n", MAPPING, - 'chk' + "chk", ).candidates; - if (findPotentialDuplicates(db, 'chk', near)[0].status !== 'flagged') { - throw new Error('one calendar day off should be flagged regardless of the hours'); + if (findPotentialDuplicates(db, "chk", near)[0].status !== "flagged") { + throw new Error( + "one calendar day off should be flagged regardless of the hours", + ); } const far = normalizeCsv( - 'Date,Description,Amount\n2026-07-04,Amazon,-52.31\n', + "Date,Description,Amount\n2026-07-04,Amazon,-52.31\n", MAPPING, - 'chk' + "chk", ).candidates; - if (findPotentialDuplicates(db, 'chk', far)[0].status !== 'new') { - throw new Error('two calendar days off should not be flagged'); + if (findPotentialDuplicates(db, "chk", far)[0].status !== "new") { + throw new Error("two calendar days off should not be flagged"); } // And the day before, symmetrically. const before = normalizeCsv( - 'Date,Description,Amount\n2026-07-01,Amazon,-52.31\n', + "Date,Description,Amount\n2026-07-01,Amazon,-52.31\n", MAPPING, - 'chk' + "chk", ).candidates; - if (findPotentialDuplicates(db, 'chk', before)[0].status !== 'flagged') { - throw new Error('the window must be symmetric'); + if (findPotentialDuplicates(db, "chk", before)[0].status !== "flagged") { + throw new Error("the window must be symmetric"); } db.close(); }); -Deno.test('a different amount on the same day is not a duplicate', () => { +Deno.test("a different amount on the same day is not a duplicate", () => { const db = testDb(); const { candidates } = normalizeCsv( - 'Date,Description,Amount\n2026-07-02,Amazon,-52.30\n', + "Date,Description,Amount\n2026-07-02,Amazon,-52.30\n", MAPPING, - 'chk' + "chk", ); - if (findPotentialDuplicates(db, 'chk', candidates)[0].status !== 'new') { - throw new Error('a one-cent difference is a different transaction'); + if (findPotentialDuplicates(db, "chk", candidates)[0].status !== "new") { + throw new Error("a one-cent difference is a different transaction"); } db.close(); }); -Deno.test('matches are consumed, so a real second charge stays importable', () => { +Deno.test("matches are consumed, so a real second charge stays importable", () => { const db = testDb(); // Two identical charges in the file, one already synced: one dupe, one new. const { candidates } = normalizeCsv( - 'Date,Description,Amount\n2026-07-02,Amazon,-52.31\n2026-07-02,Amazon,-52.31\n', + "Date,Description,Amount\n2026-07-02,Amazon,-52.31\n2026-07-02,Amazon,-52.31\n", MAPPING, - 'chk' + "chk", ); - const results = findPotentialDuplicates(db, 'chk', candidates); + const results = findPotentialDuplicates(db, "chk", candidates); const statuses = results.map((r) => r.status).sort(); - if (statuses.join(',') !== 'flagged,new') { - throw new Error(`expected one flagged and one new, got ${statuses.join(',')}`); + if (statuses.join(",") !== "flagged,new") { + throw new Error( + `expected one flagged and one new, got ${statuses.join(",")}`, + ); } db.close(); }); -Deno.test('commit defaults flagged rows to skip and leaves the synced row alone', () => { +Deno.test("commit defaults flagged rows to skip and leaves the synced row alone", () => { const db = testDb(); - const id = draft(db, 'Date,Description,Amount\n2026-07-02,Amazon,-52.31\n2026-06-01,Shell,-40.00\n'); + const id = draft( + db, + "Date,Description,Amount\n2026-07-02,Amazon,-52.31\n2026-06-01,Shell,-40.00\n", + ); const result = commitImport(db, id); - if (result.skipped !== 1) throw new Error(`expected 1 skipped, got ${result.skipped}`); - if (result.inserted !== 1) throw new Error(`expected 1 inserted, got ${result.inserted}`); + if (result.skipped !== 1) { + throw new Error(`expected 1 skipped, got ${result.skipped}`); + } + if (result.inserted !== 1) { + throw new Error(`expected 1 inserted, got ${result.inserted}`); + } const amazon = db - .prepare("SELECT COUNT(*) AS n FROM transactions WHERE account_id='chk' AND amount_cents=-5231 AND removed_at IS NULL") + .prepare( + "SELECT COUNT(*) AS n FROM transactions WHERE account_id='chk' AND amount_cents=-5231 AND removed_at IS NULL", + ) .get() as { n: number }; - if (amazon.n !== 1) throw new Error(`double-counted: ${amazon.n} rows at -5231`); + if (amazon.n !== 1) { + throw new Error(`double-counted: ${amazon.n} rows at -5231`); + } const synced = db - .prepare("SELECT description, import_id FROM transactions WHERE sfin_id='sfin-amazon'") + .prepare( + "SELECT description, import_id FROM transactions WHERE sfin_id='sfin-amazon'", + ) .get() as { description: string; import_id: number | null }; - if (synced.description !== 'AMZN Mktp US*2K4LM9QR3' || synced.import_id !== null) { - throw new Error('the synced row must be untouched'); + if ( + synced.description !== "AMZN Mktp US*2K4LM9QR3" || synced.import_id !== null + ) { + throw new Error("the synced row must be untouched"); } db.close(); }); -Deno.test('a kept flagged row is imported alongside the existing one', () => { +Deno.test("a kept flagged row is imported alongside the existing one", () => { const db = testDb(); - const csv = 'Date,Description,Amount\n2026-07-02,Amazon,-52.31\n'; + const csv = "Date,Description,Amount\n2026-07-02,Amazon,-52.31\n"; const id = draft(db, csv); - const { candidates } = normalizeCsv(csv, MAPPING, 'chk'); - saveDecisions(db, id, { [candidates[0].syntheticId]: 'keep' }); + const { candidates } = normalizeCsv(csv, MAPPING, "chk"); + saveDecisions(db, id, { [candidates[0].syntheticId]: "keep" }); const result = commitImport(db, id); - if (result.inserted !== 1) throw new Error(`expected 1 inserted, got ${result.inserted}`); + if (result.inserted !== 1) { + throw new Error(`expected 1 inserted, got ${result.inserted}`); + } const rows = db - .prepare("SELECT COUNT(*) AS n FROM transactions WHERE account_id='chk' AND amount_cents=-5231 AND removed_at IS NULL") + .prepare( + "SELECT COUNT(*) AS n FROM transactions WHERE account_id='chk' AND amount_cents=-5231 AND removed_at IS NULL", + ) .get() as { n: number }; if (rows.n !== 2) throw new Error(`expected both rows, got ${rows.n}`); db.close(); }); -Deno.test('import never disturbs pending rows, snapshots, or account state', () => { +Deno.test("import never disturbs pending rows, snapshots, or account state", () => { const db = testDb(); - const before = db.prepare("SELECT state, last_successful_data_at FROM accounts WHERE id='chk'").get() as { + const before = db.prepare( + "SELECT state, last_successful_data_at FROM accounts WHERE id='chk'", + ).get() as { state: string; last_successful_data_at: string; }; - const id = draft(db, 'Date,Description,Amount\n2026-06-01,Shell,-40.00\n'); + const id = draft(db, "Date,Description,Amount\n2026-06-01,Shell,-40.00\n"); commitImport(db, id); // The stale-pending sweep in ingestTransactions would have removed this; the @@ -225,150 +276,213 @@ Deno.test('import never disturbs pending rows, snapshots, or account state', () const pending = db .prepare("SELECT removed_at FROM transactions WHERE sfin_id='sfin-pending'") .get() as { removed_at: string | null }; - if (pending.removed_at !== null) throw new Error('an import must never remove a pending row'); + if (pending.removed_at !== null) { + throw new Error("an import must never remove a pending row"); + } - const snaps = db.prepare('SELECT COUNT(*) AS n FROM balance_snapshots').get() as { n: number }; - if (snaps.n !== 0) throw new Error('an import must not write balance snapshots'); + const snaps = db.prepare("SELECT COUNT(*) AS n FROM balance_snapshots") + .get() as { n: number }; + if (snaps.n !== 0) { + throw new Error("an import must not write balance snapshots"); + } - const after = db.prepare("SELECT state, last_successful_data_at FROM accounts WHERE id='chk'").get() as { + const after = db.prepare( + "SELECT state, last_successful_data_at FROM accounts WHERE id='chk'", + ).get() as { state: string; last_successful_data_at: string; }; - if (after.state !== before.state || after.last_successful_data_at !== before.last_successful_data_at) { - throw new Error('an import must not touch account state'); + if ( + after.state !== before.state || + after.last_successful_data_at !== before.last_successful_data_at + ) { + throw new Error("an import must not touch account state"); } db.close(); }); -Deno.test('re-importing the same file inserts nothing', () => { +Deno.test("re-importing the same file inserts nothing", () => { const db = testDb(); - const csv = 'Date,Description,Amount\n2026-06-01,Shell,-40.00\n2026-06-02,Coffee,-4.75\n'; + const csv = + "Date,Description,Amount\n2026-06-01,Shell,-40.00\n2026-06-02,Coffee,-4.75\n"; commitImport(db, draft(db, csv)); const second = commitImport(db, draft(db, csv)); - if (second.inserted !== 0) throw new Error(`expected 0 inserted, got ${second.inserted}`); - if (second.alreadyPresent !== 2) throw new Error(`expected 2 already-present, got ${second.alreadyPresent}`); + if (second.inserted !== 0) { + throw new Error(`expected 0 inserted, got ${second.inserted}`); + } + if (second.alreadyPresent !== 2) { + throw new Error(`expected 2 already-present, got ${second.alreadyPresent}`); + } const total = db - .prepare("SELECT COUNT(*) AS n FROM transactions WHERE import_id IS NOT NULL AND removed_at IS NULL") + .prepare( + "SELECT COUNT(*) AS n FROM transactions WHERE import_id IS NOT NULL AND removed_at IS NULL", + ) .get() as { n: number }; - if (total.n !== 2) throw new Error(`expected 2 imported rows total, got ${total.n}`); + if (total.n !== 2) { + throw new Error(`expected 2 imported rows total, got ${total.n}`); + } db.close(); }); -Deno.test('an overlapping file adds only the new rows', () => { +Deno.test("an overlapping file adds only the new rows", () => { const db = testDb(); - commitImport(db, draft(db, 'Date,Description,Amount\n2026-06-01,Shell,-40.00\n2026-06-02,Coffee,-4.75\n')); + commitImport( + db, + draft( + db, + "Date,Description,Amount\n2026-06-01,Shell,-40.00\n2026-06-02,Coffee,-4.75\n", + ), + ); const second = commitImport( db, - draft(db, 'Date,Description,Amount\n2026-06-02,Coffee,-4.75\n2026-06-03,Bagel,-3.00\n') + draft( + db, + "Date,Description,Amount\n2026-06-02,Coffee,-4.75\n2026-06-03,Bagel,-3.00\n", + ), ); - if (second.inserted !== 1) throw new Error(`expected 1 inserted, got ${second.inserted}`); - if (second.alreadyPresent !== 1) throw new Error(`expected 1 already-present, got ${second.alreadyPresent}`); + if (second.inserted !== 1) { + throw new Error(`expected 1 inserted, got ${second.inserted}`); + } + if (second.alreadyPresent !== 1) { + throw new Error(`expected 1 already-present, got ${second.alreadyPresent}`); + } db.close(); }); -Deno.test('committed rows carry import_id and are categorized by existing rules', () => { +Deno.test("committed rows carry import_id and are categorized by existing rules", () => { const db = testDb(); createRule(db, { - pattern: 'shell', - matchType: 'contains', + pattern: "shell", + matchType: "contains", categoryId: 2, - createdByDid: 'did:plc:test' + createdByDid: "did:plc:test", }); - const id = draft(db, 'Date,Description,Amount\n2026-06-01,SHELL OIL 4417,-40.00\n'); + const id = draft( + db, + "Date,Description,Amount\n2026-06-01,SHELL OIL 4417,-40.00\n", + ); const result = commitImport(db, id); - if (result.ruleCategorized < 1) throw new Error('rules should have caught the imported row'); + if (result.ruleCategorized < 1) { + throw new Error("rules should have caught the imported row"); + } const row = db - .prepare("SELECT import_id, category_id FROM transactions WHERE description='SHELL OIL 4417'") + .prepare( + "SELECT import_id, category_id FROM transactions WHERE description='SHELL OIL 4417'", + ) .get() as { import_id: number; category_id: number | null }; - if (row.import_id !== id) throw new Error(`import_id not stamped: ${row.import_id}`); - if (row.category_id !== 2) throw new Error('imported row should be rule-categorized'); + if (row.import_id !== id) { + throw new Error(`import_id not stamped: ${row.import_id}`); + } + if (row.category_id !== 2) { + throw new Error("imported row should be rule-categorized"); + } const event = db .prepare( `SELECT source FROM categorization_events WHERE transaction_id = - (SELECT id FROM transactions WHERE description='SHELL OIL 4417')` + (SELECT id FROM transactions WHERE description='SHELL OIL 4417')`, ) .get() as { source: string }; - if (event.source !== 'rule') throw new Error(`expected a rule event, got ${event.source}`); + if (event.source !== "rule") { + throw new Error(`expected a rule event, got ${event.source}`); + } db.close(); }); -Deno.test('backfilled rows reach the month report — the point of the feature', () => { +Deno.test("backfilled rows reach the month report — the point of the feature", () => { // The whole path: a CSV fills a gap, existing rules categorize it, and the // month that had no data now reports. Report totals only count categorized // rows, so this exercises import -> rules -> report end to end. const db = testDb(); - db.prepare("INSERT INTO categories (name, kind, created_at) VALUES ('Salary','income',?)").run( - new Date().toISOString() + db.prepare( + "INSERT INTO categories (name, kind, created_at) VALUES ('Salary','income',?)", + ).run( + new Date().toISOString(), ); createRule(db, { - pattern: 'shell', - matchType: 'contains', + pattern: "shell", + matchType: "contains", categoryId: 2, - createdByDid: 'did:plc:test' + createdByDid: "did:plc:test", }); createRule(db, { - pattern: 'paycheck', - matchType: 'contains', + pattern: "paycheck", + matchType: "contains", categoryId: 3, - createdByDid: 'did:plc:test' + createdByDid: "did:plc:test", }); - if (monthlyReport(db, '2026-06').expenseTotalCents !== 0) throw new Error('June should start empty'); + if (monthlyReport(db, "2026-06").expenseTotalCents !== 0) { + throw new Error("June should start empty"); + } - const id = draft(db, 'Date,Description,Amount\n2026-06-15,SHELL OIL,-40.00\n2026-06-20,ACME PAYCHECK,1000.00\n'); + const id = draft( + db, + "Date,Description,Amount\n2026-06-15,SHELL OIL,-40.00\n2026-06-20,ACME PAYCHECK,1000.00\n", + ); commitImport(db, id); - const june = monthlyReport(db, '2026-06'); + const june = monthlyReport(db, "2026-06"); if (june.expenseTotalCents !== -4000) { - throw new Error(`imported expense missing from the report: ${june.expenseTotalCents}`); + throw new Error( + `imported expense missing from the report: ${june.expenseTotalCents}`, + ); } if (june.incomeTotalCents !== 100000) { - throw new Error(`imported income missing from the report: ${june.incomeTotalCents}`); + throw new Error( + `imported income missing from the report: ${june.incomeTotalCents}`, + ); } // And they leave again with an undo. undoImport(db, id); - const after = monthlyReport(db, '2026-06'); + const after = monthlyReport(db, "2026-06"); if (after.expenseTotalCents !== 0 || after.incomeTotalCents !== 0) { - throw new Error('undone rows must leave the report'); + throw new Error("undone rows must leave the report"); } db.close(); }); -Deno.test('an import writes no snapshots, so net worth is untouched', () => { +Deno.test("an import writes no snapshots, so net worth is untouched", () => { const db = testDb(); const before = netWorthSeries(db).length; - commitImport(db, draft(db, 'Date,Description,Amount\n2026-06-15,Shell,-40.00\n')); + commitImport( + db, + draft(db, "Date,Description,Amount\n2026-06-15,Shell,-40.00\n"), + ); if (netWorthSeries(db).length !== before) { - throw new Error('an import must not add points to the net worth series'); + throw new Error("an import must not add points to the net worth series"); } db.close(); }); -Deno.test('preview states the range and the counts', () => { +Deno.test("preview states the range and the counts", () => { const db = testDb(); const id = draft( db, - 'Date,Description,Amount\n2026-06-01,Shell,-40.00\n2026-07-02,Amazon,-52.31\nbad,Row,-1.00\n' + "Date,Description,Amount\n2026-06-01,Shell,-40.00\n2026-07-02,Amazon,-52.31\nbad,Row,-1.00\n", ); const preview = previewImport(db, id); if (preview.rangeStart !== Math.floor(Date.UTC(2026, 5, 1, 12) / 1000)) { - throw new Error('wrong start'); + throw new Error("wrong start"); } - if (preview.rangeEnd !== JULY_02_NOON) throw new Error('wrong end'); + if (preview.rangeEnd !== JULY_02_NOON) throw new Error("wrong end"); if (preview.newCount !== 1) throw new Error(`newCount ${preview.newCount}`); - if (preview.flaggedCount !== 1) throw new Error(`flaggedCount ${preview.flaggedCount}`); - if (preview.errors.length !== 1) throw new Error('the bad row should be reported'); + if (preview.flaggedCount !== 1) { + throw new Error(`flaggedCount ${preview.flaggedCount}`); + } + if (preview.errors.length !== 1) { + throw new Error("the bad row should be reported"); + } db.close(); }); -Deno.test('a draft cannot be committed twice', () => { +Deno.test("a draft cannot be committed twice", () => { const db = testDb(); - const id = draft(db, 'Date,Description,Amount\n2026-06-01,Shell,-40.00\n'); + const id = draft(db, "Date,Description,Amount\n2026-06-01,Shell,-40.00\n"); commitImport(db, id); let threw = false; try { @@ -376,107 +490,147 @@ Deno.test('a draft cannot be committed twice', () => { } catch { threw = true; } - if (!threw) throw new Error('expected a committed import to refuse a second commit'); + if (!threw) { + throw new Error("expected a committed import to refuse a second commit"); + } db.close(); }); -Deno.test('undo removes exactly that import and spares synced rows', () => { +Deno.test("undo removes exactly that import and spares synced rows", () => { const db = testDb(); - const id = draft(db, 'Date,Description,Amount\n2026-06-01,Shell,-40.00\n2026-06-02,Coffee,-4.75\n'); + const id = draft( + db, + "Date,Description,Amount\n2026-06-01,Shell,-40.00\n2026-06-02,Coffee,-4.75\n", + ); commitImport(db, id); const removed = undoImport(db, id); if (removed !== 2) throw new Error(`expected 2 removed, got ${removed}`); const live = db - .prepare('SELECT COUNT(*) AS n FROM transactions WHERE import_id = ? AND removed_at IS NULL') + .prepare( + "SELECT COUNT(*) AS n FROM transactions WHERE import_id = ? AND removed_at IS NULL", + ) .get(id) as { n: number }; - if (live.n !== 0) throw new Error('undo should remove every row of the import'); + if (live.n !== 0) { + throw new Error("undo should remove every row of the import"); + } const synced = db .prepare("SELECT removed_at FROM transactions WHERE sfin_id='sfin-amazon'") .get() as { removed_at: string | null }; - if (synced.removed_at !== null) throw new Error('undo must not touch synced rows'); + if (synced.removed_at !== null) { + throw new Error("undo must not touch synced rows"); + } - const status = db.prepare('SELECT status FROM imports WHERE id = ?').get(id) as { status: string }; - if (status.status !== 'undone') throw new Error(`expected undone, got ${status.status}`); + const status = db.prepare("SELECT status FROM imports WHERE id = ?").get( + id, + ) as { status: string }; + if (status.status !== "undone") { + throw new Error(`expected undone, got ${status.status}`); + } db.close(); }); -Deno.test('event history survives undo', () => { +Deno.test("event history survives undo", () => { const db = testDb(); - const id = draft(db, 'Date,Description,Amount\n2026-06-01,Shell,-40.00\n'); + const id = draft(db, "Date,Description,Amount\n2026-06-01,Shell,-40.00\n"); commitImport(db, id); - const txn = db.prepare("SELECT id FROM transactions WHERE description='Shell'").get() as { + const txn = db.prepare( + "SELECT id FROM transactions WHERE description='Shell'", + ).get() as { id: number; }; - categorizeManually(db, txn.id, 2, 'did:plc:test'); + categorizeManually(db, txn.id, 2, "did:plc:test"); undoImport(db, id); const events = db - .prepare('SELECT COUNT(*) AS n FROM categorization_events WHERE transaction_id = ?') + .prepare( + "SELECT COUNT(*) AS n FROM categorization_events WHERE transaction_id = ?", + ) .get(txn.id) as { n: number }; - if (events.n === 0) throw new Error('the append-only event log must outlive the rows'); + if (events.n === 0) { + throw new Error("the append-only event log must outlive the rows"); + } db.close(); }); -Deno.test('re-import after a corrected mapping does not resurrect the old rows', () => { +Deno.test("re-import after a corrected mapping does not resurrect the old rows", () => { const db = testDb(); // A day-first file misread as month-first: June 7 instead of July 6. - const csv = 'Date,Description,Amount\n06/07/2026,Shell,-40.00\n'; - const wrong = createDraftImport(db, 'chk', 'bank.csv', csv); - saveMapping(db, wrong, { ...MAPPING, dateFormat: 'mdy' }); + const csv = "Date,Description,Amount\n06/07/2026,Shell,-40.00\n"; + const wrong = createDraftImport(db, "chk", "bank.csv", csv); + saveMapping(db, wrong, { ...MAPPING, dateFormat: "mdy" }); commitImport(db, wrong); undoImport(db, wrong); - const right = createDraftImport(db, 'chk', 'bank.csv', csv); - saveMapping(db, right, { ...MAPPING, dateFormat: 'dmy' }); + const right = createDraftImport(db, "chk", "bank.csv", csv); + saveMapping(db, right, { ...MAPPING, dateFormat: "dmy" }); const result = commitImport(db, right); - if (result.inserted !== 1) throw new Error(`expected a fresh insert, got ${result.inserted}`); + if (result.inserted !== 1) { + throw new Error(`expected a fresh insert, got ${result.inserted}`); + } const live = db - .prepare("SELECT posted FROM transactions WHERE removed_at IS NULL AND import_id IS NOT NULL") + .prepare( + "SELECT posted FROM transactions WHERE removed_at IS NULL AND import_id IS NOT NULL", + ) .all() as { posted: number }[]; - if (live.length !== 1) throw new Error(`expected exactly 1 live imported row, got ${live.length}`); + if (live.length !== 1) { + throw new Error(`expected exactly 1 live imported row, got ${live.length}`); + } if (live[0].posted !== Math.floor(Date.UTC(2026, 6, 6, 12) / 1000)) { - throw new Error('the corrected date should be July 6'); + throw new Error("the corrected date should be July 6"); } db.close(); }); -Deno.test('undo then re-import with the same mapping revives rather than duplicating', () => { +Deno.test("undo then re-import with the same mapping revives rather than duplicating", () => { const db = testDb(); - const csv = 'Date,Description,Amount\n2026-06-01,Shell,-40.00\n'; + const csv = "Date,Description,Amount\n2026-06-01,Shell,-40.00\n"; const first = draft(db, csv); commitImport(db, first); undoImport(db, first); const second = draft(db, csv); const result = commitImport(db, second); - if (result.revived !== 1) throw new Error(`expected 1 revived, got ${result.revived}`); + if (result.revived !== 1) { + throw new Error(`expected 1 revived, got ${result.revived}`); + } const rows = db - .prepare("SELECT import_id FROM transactions WHERE description='Shell' AND removed_at IS NULL") + .prepare( + "SELECT import_id FROM transactions WHERE description='Shell' AND removed_at IS NULL", + ) .all() as { import_id: number }[]; - if (rows.length !== 1) throw new Error(`expected 1 live row, got ${rows.length}`); - if (rows[0].import_id !== second) throw new Error('the revived row should belong to the new import'); + if (rows.length !== 1) { + throw new Error(`expected 1 live row, got ${rows.length}`); + } + if (rows[0].import_id !== second) { + throw new Error("the revived row should belong to the new import"); + } db.close(); }); -Deno.test('cancelling a draft deletes it and its archived bytes', () => { +Deno.test("cancelling a draft deletes it and its archived bytes", () => { const db = testDb(); - const id = createDraftImport(db, 'chk', 'scratch.csv', 'Date,Description,Amount\n2026-06-01,Shell,-40.00\n'); + const id = createDraftImport( + db, + "chk", + "scratch.csv", + "Date,Description,Amount\n2026-06-01,Shell,-40.00\n", + ); deleteDraftImport(db, id); - const row = db.prepare('SELECT id FROM imports WHERE id = ?').get(id); - if (row) throw new Error('the draft row and its payload should be gone'); + const row = db.prepare("SELECT id FROM imports WHERE id = ?").get(id); + if (row) throw new Error("the draft row and its payload should be gone"); db.close(); }); -Deno.test('cancel refuses a committed import so history is never hard-deleted', () => { +Deno.test("cancel refuses a committed import so history is never hard-deleted", () => { const db = testDb(); - const id = draft(db, 'Date,Description,Amount\n2026-06-01,Shell,-40.00\n'); + const id = draft(db, "Date,Description,Amount\n2026-06-01,Shell,-40.00\n"); commitImport(db, id); let threw = false; @@ -485,42 +639,49 @@ Deno.test('cancel refuses a committed import so history is never hard-deleted', } catch { threw = true; } - if (!threw) throw new Error('a committed import must not be hard-deletable'); + if (!threw) throw new Error("a committed import must not be hard-deletable"); // It survives, and its rows with it — undo is the only removal path. - const still = db.prepare('SELECT status FROM imports WHERE id = ?').get(id) as { status: string }; - if (still.status !== 'committed') throw new Error('the committed import must be untouched'); + const still = db.prepare("SELECT status FROM imports WHERE id = ?").get( + id, + ) as { status: string }; + if (still.status !== "committed") { + throw new Error("the committed import must be untouched"); + } if ( - (db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE import_id = ?").get(id) as { n: number }) + (db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE import_id = ?") + .get(id) as { n: number }) .n !== 1 ) { - throw new Error('the committed import kept its transaction'); + throw new Error("the committed import kept its transaction"); } db.close(); }); -Deno.test('only a committed import can be undone', () => { +Deno.test("only a committed import can be undone", () => { const db = testDb(); - const id = draft(db, 'Date,Description,Amount\n2026-06-01,Shell,-40.00\n'); + const id = draft(db, "Date,Description,Amount\n2026-06-01,Shell,-40.00\n"); let threw = false; try { undoImport(db, id); } catch { threw = true; } - if (!threw) throw new Error('expected undoing a draft to be refused'); + if (!threw) throw new Error("expected undoing a draft to be refused"); db.close(); }); -Deno.test('JULY_03 nearby row exercises the window boundary', () => { +Deno.test("JULY_03 nearby row exercises the window boundary", () => { // Guards the constant itself: a row exactly one day out must still flag. const db = testDb(); const { candidates } = normalizeCsv( `Date,Description,Amount\n2026-07-03,Amazon,-52.31\n`, MAPPING, - 'chk' + "chk", ); - const [result] = findPotentialDuplicates(db, 'chk', candidates); - if (result.candidate.posted !== JULY_03_NOON) throw new Error('fixture drift'); - if (result.status !== 'flagged') throw new Error('boundary row should flag'); + const [result] = findPotentialDuplicates(db, "chk", candidates); + if (result.candidate.posted !== JULY_03_NOON) { + throw new Error("fixture drift"); + } + if (result.status !== "flagged") throw new Error("boundary row should flag"); db.close(); }); diff --git a/src/lib/server/services/imports.ts b/src/lib/server/services/imports.ts index 458681c..8d956f0 100644 --- a/src/lib/server/services/imports.ts +++ b/src/lib/server/services/imports.ts @@ -7,9 +7,13 @@ // never mutates a synced row, never writes balance snapshots, and never touches // account state — all of that is exclusively sync's authority. -import type { DatabaseSync } from 'node:sqlite'; -import { normalizeCsv, type CsvCandidate, type CsvMapping } from './csv-import.ts'; -import { applyRulesToUncategorized } from './rules.ts'; +import type { DatabaseSync } from "node:sqlite"; +import { + type CsvCandidate, + type CsvMapping, + normalizeCsv, +} from "./csv-import.ts"; +import { applyRulesToUncategorized } from "./rules.ts"; /** * A CSV's date is often the transaction date while SimpleFIN's posted date trails @@ -27,7 +31,7 @@ const DUPLICATE_WINDOW_DAYS = 1; /** Whole days since the epoch, UTC. Bank data is post-1970, so truncation is floor. */ const UTC_DAY = `CAST(COALESCE(posted, transacted_at) / 86400 AS INTEGER)`; -export type ImportStatus = 'draft' | 'committed' | 'undone'; +export type ImportStatus = "draft" | "committed" | "undone"; export interface ImportRecord { id: number; @@ -36,7 +40,7 @@ export interface ImportRecord { uploadedAt: string; payload: string; mapping: CsvMapping | null; - decisions: Record; + decisions: Record; status: ImportStatus; committedAt: string | null; undoneAt: string | null; @@ -48,11 +52,11 @@ export interface ExistingMatch { amountCents: number; description: string; payee: string | null; - source: 'synced' | 'imported'; + source: "synced" | "imported"; } /** `already-present` rows are never surfaced — identity already made them a no-op. */ -export type CandidateStatus = 'new' | 'already-present' | 'flagged'; +export type CandidateStatus = "new" | "already-present" | "flagged"; export interface ClassifiedCandidate { candidate: CsvCandidate; @@ -81,10 +85,12 @@ function hydrate(row: ImportRow): ImportRecord { uploadedAt: row.uploaded_at, payload: row.payload, mapping: row.mapping ? (JSON.parse(row.mapping) as CsvMapping) : null, - decisions: row.decisions ? (JSON.parse(row.decisions) as Record) : {}, + decisions: row.decisions + ? (JSON.parse(row.decisions) as Record) + : {}, status: row.status, committedAt: row.committed_at, - undoneAt: row.undone_at + undoneAt: row.undone_at, }; } @@ -93,37 +99,44 @@ export function createDraftImport( db: DatabaseSync, accountId: string, filename: string | null, - payload: string + payload: string, ): number { const account = db - .prepare("SELECT id, state FROM accounts WHERE id = ? AND state != 'HIDDEN'") + .prepare( + "SELECT id, state FROM accounts WHERE id = ? AND state != 'HIDDEN'", + ) .get(accountId) as { id: string } | undefined; if (!account) throw new Error(`Unknown or hidden account: ${accountId}`); const result = db .prepare( `INSERT INTO imports (account_id, filename, uploaded_at, payload, status) - VALUES (?, ?, ?, ?, 'draft')` + VALUES (?, ?, ?, ?, 'draft')`, ) .run(accountId, filename, new Date().toISOString(), payload); return Number(result.lastInsertRowid); } -export function getImport(db: DatabaseSync, importId: number): ImportRecord | null { - const row = db.prepare('SELECT * FROM imports WHERE id = ?').get(importId) as +export function getImport( + db: DatabaseSync, + importId: number, +): ImportRecord | null { + const row = db.prepare("SELECT * FROM imports WHERE id = ?").get(importId) as | ImportRow | undefined; return row ? hydrate(row) : null; } -export function listImports(db: DatabaseSync): (ImportRecord & { rowCount: number })[] { +export function listImports( + db: DatabaseSync, +): (ImportRecord & { rowCount: number })[] { const rows = db .prepare( `SELECT i.*, ( SELECT COUNT(*) FROM transactions t WHERE t.import_id = i.id AND t.removed_at IS NULL ) AS row_count - FROM imports i ORDER BY i.id DESC` + FROM imports i ORDER BY i.id DESC`, ) .all() as unknown as (ImportRow & { row_count: number })[]; return rows.map((r) => ({ ...hydrate(r), rowCount: r.row_count })); @@ -132,24 +145,33 @@ export function listImports(db: DatabaseSync): (ImportRecord & { rowCount: numbe function requireDraft(db: DatabaseSync, importId: number): ImportRecord { const record = getImport(db, importId); if (!record) throw new Error(`Unknown import: ${importId}`); - if (record.status !== 'draft') throw new Error(`Import ${importId} is already ${record.status}.`); + if (record.status !== "draft") { + throw new Error(`Import ${importId} is already ${record.status}.`); + } return record; } -export function saveMapping(db: DatabaseSync, importId: number, mapping: CsvMapping): void { +export function saveMapping( + db: DatabaseSync, + importId: number, + mapping: CsvMapping, +): void { requireDraft(db, importId); - db.prepare('UPDATE imports SET mapping = ? WHERE id = ?').run(JSON.stringify(mapping), importId); + db.prepare("UPDATE imports SET mapping = ? WHERE id = ?").run( + JSON.stringify(mapping), + importId, + ); } export function saveDecisions( db: DatabaseSync, importId: number, - decisions: Record + decisions: Record, ): void { requireDraft(db, importId); - db.prepare('UPDATE imports SET decisions = ? WHERE id = ?').run( + db.prepare("UPDATE imports SET decisions = ? WHERE id = ?").run( JSON.stringify(decisions), - importId + importId, ); } @@ -167,10 +189,10 @@ export function saveDecisions( export function findPotentialDuplicates( db: DatabaseSync, accountId: string, - candidates: CsvCandidate[] + candidates: CsvCandidate[], ): ClassifiedCandidate[] { const existing = db.prepare( - 'SELECT id FROM transactions WHERE account_id = ? AND sfin_id = ? AND removed_at IS NULL' + "SELECT id FROM transactions WHERE account_id = ? AND sfin_id = ? AND removed_at IS NULL", ); const nearby = db.prepare( `SELECT id, COALESCE(posted, transacted_at) AS effective_at, amount_cents, @@ -178,13 +200,13 @@ export function findPotentialDuplicates( FROM transactions WHERE account_id = ? AND removed_at IS NULL AND amount_cents = ? AND ${UTC_DAY} BETWEEN ? AND ? - ORDER BY id` + ORDER BY id`, ); const consumed = new Set(); return candidates.map((candidate) => { if (existing.get(accountId, candidate.syntheticId)) { - return { candidate, status: 'already-present' as const, match: null }; + return { candidate, status: "already-present" as const, match: null }; } const day = Math.floor(candidate.posted / 86400); @@ -192,7 +214,7 @@ export function findPotentialDuplicates( accountId, candidate.amountCents, day - DUPLICATE_WINDOW_DAYS, - day + DUPLICATE_WINDOW_DAYS + day + DUPLICATE_WINDOW_DAYS, ) as { id: number; effective_at: number | null; @@ -203,20 +225,22 @@ export function findPotentialDuplicates( }[]; const hit = rows.find((r) => !consumed.has(r.id)); - if (!hit) return { candidate, status: 'new' as const, match: null }; + if (!hit) return { candidate, status: "new" as const, match: null }; consumed.add(hit.id); return { candidate, - status: 'flagged' as const, + status: "flagged" as const, match: { id: hit.id, effectiveAt: hit.effective_at, amountCents: hit.amount_cents, description: hit.description, payee: hit.payee, - source: hit.import_id === null ? ('synced' as const) : ('imported' as const) - } + source: hit.import_id === null + ? ("synced" as const) + : ("imported" as const), + }, }; }); } @@ -236,12 +260,21 @@ export interface ImportPreview { * Re-parse the archived bytes with the stored mapping. Pure with respect to the * archive: every wizard step can call this instead of holding state elsewhere. */ -export function previewImport(db: DatabaseSync, importId: number): ImportPreview { +export function previewImport( + db: DatabaseSync, + importId: number, +): ImportPreview { const record = getImport(db, importId); if (!record) throw new Error(`Unknown import: ${importId}`); - if (!record.mapping) throw new Error(`Import ${importId} has no mapping yet.`); + if (!record.mapping) { + throw new Error(`Import ${importId} has no mapping yet.`); + } - const { candidates, errors } = normalizeCsv(record.payload, record.mapping, record.accountId); + const { candidates, errors } = normalizeCsv( + record.payload, + record.mapping, + record.accountId, + ); const classified = findPotentialDuplicates(db, record.accountId, candidates); const dates = candidates.map((c) => c.posted); @@ -250,9 +283,10 @@ export function previewImport(db: DatabaseSync, importId: number): ImportPreview errors, rangeStart: dates.length ? Math.min(...dates) : null, rangeEnd: dates.length ? Math.max(...dates) : null, - newCount: classified.filter((c) => c.status === 'new').length, - alreadyPresentCount: classified.filter((c) => c.status === 'already-present').length, - flaggedCount: classified.filter((c) => c.status === 'flagged').length + newCount: classified.filter((c) => c.status === "new").length, + alreadyPresentCount: + classified.filter((c) => c.status === "already-present").length, + flaggedCount: classified.filter((c) => c.status === "flagged").length, }; } @@ -271,9 +305,15 @@ export interface CommitResult { */ export function commitImport(db: DatabaseSync, importId: number): CommitResult { const record = requireDraft(db, importId); - if (!record.mapping) throw new Error(`Import ${importId} has no mapping yet.`); + if (!record.mapping) { + throw new Error(`Import ${importId} has no mapping yet.`); + } - const { candidates } = normalizeCsv(record.payload, record.mapping, record.accountId); + const { candidates } = normalizeCsv( + record.payload, + record.mapping, + record.accountId, + ); const classified = findPotentialDuplicates(db, record.accountId, candidates); const now = new Date().toISOString(); @@ -282,31 +322,34 @@ export function commitImport(db: DatabaseSync, importId: number): CommitResult { let skipped = 0; let alreadyPresent = 0; - db.exec('BEGIN'); + db.exec("BEGIN"); try { const findAny = db.prepare( - 'SELECT id FROM transactions WHERE account_id = ? AND sfin_id = ?' + "SELECT id FROM transactions WHERE account_id = ? AND sfin_id = ?", ); const revive = db.prepare( `UPDATE transactions SET removed_at = NULL, import_id = ?, posted = ?, transacted_at = ?, amount_cents = ?, description = ?, payee = ?, memo = ?, pending = 0 - WHERE id = ?` + WHERE id = ?`, ); const insert = db.prepare( `INSERT INTO transactions (account_id, sfin_id, posted, transacted_at, amount_cents, description, payee, memo, pending, import_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, ); for (const { candidate, status } of classified) { - if (status === 'already-present') { + if (status === "already-present") { alreadyPresent++; continue; } // Flagged rows default to skip: where both sources claim a transaction the // synced row is strictly better, so declining the CSV's copy loses nothing. - if (status === 'flagged' && record.decisions[candidate.syntheticId] !== 'keep') { + if ( + status === "flagged" && + record.decisions[candidate.syntheticId] !== "keep" + ) { skipped++; continue; } @@ -325,7 +368,7 @@ export function commitImport(db: DatabaseSync, importId: number): CommitResult { candidate.description, candidate.payee, candidate.memo, - prior.id + prior.id, ); revived++; } else { @@ -339,19 +382,21 @@ export function commitImport(db: DatabaseSync, importId: number): CommitResult { candidate.payee, candidate.memo, importId, - now + now, ); inserted++; } } - db.prepare("UPDATE imports SET status = 'committed', committed_at = ? WHERE id = ?").run( + db.prepare( + "UPDATE imports SET status = 'committed', committed_at = ? WHERE id = ?", + ).run( now, - importId + importId, ); - db.exec('COMMIT'); + db.exec("COMMIT"); } catch (err) { - db.exec('ROLLBACK'); + db.exec("ROLLBACK"); throw err; } @@ -369,24 +414,30 @@ export function commitImport(db: DatabaseSync, importId: number): CommitResult { export function undoImport(db: DatabaseSync, importId: number): number { const record = getImport(db, importId); if (!record) throw new Error(`Unknown import: ${importId}`); - if (record.status !== 'committed') { - throw new Error(`Only a committed import can be undone; ${importId} is ${record.status}.`); + if (record.status !== "committed") { + throw new Error( + `Only a committed import can be undone; ${importId} is ${record.status}.`, + ); } const now = new Date().toISOString(); - db.exec('BEGIN'); + db.exec("BEGIN"); try { const result = db - .prepare('UPDATE transactions SET removed_at = ? WHERE import_id = ? AND removed_at IS NULL') + .prepare( + "UPDATE transactions SET removed_at = ? WHERE import_id = ? AND removed_at IS NULL", + ) .run(now, importId); - db.prepare("UPDATE imports SET status = 'undone', undone_at = ? WHERE id = ?").run( + db.prepare( + "UPDATE imports SET status = 'undone', undone_at = ? WHERE id = ?", + ).run( now, - importId + importId, ); - db.exec('COMMIT'); + db.exec("COMMIT"); return Number(result.changes); } catch (err) { - db.exec('ROLLBACK'); + db.exec("ROLLBACK"); throw err; } } @@ -401,9 +452,14 @@ export function undoImport(db: DatabaseSync, importId: number): number { export function deleteDraftImport(db: DatabaseSync, importId: number): void { const record = getImport(db, importId); if (!record) throw new Error(`Unknown import: ${importId}`); - if (record.status !== 'draft') { - throw new Error(`Only an unfinished import can be cancelled; ${importId} is ${record.status}.`); + if (record.status !== "draft") { + throw new Error( + `Only an unfinished import can be cancelled; ${importId} is ${record.status}.`, + ); } // A draft has no transactions referencing it, so the row deletes cleanly. - db.prepare('DELETE FROM imports WHERE id = ? AND status = ?').run(importId, 'draft'); + db.prepare("DELETE FROM imports WHERE id = ? AND status = ?").run( + importId, + "draft", + ); } diff --git a/src/lib/server/services/ledger.test.ts b/src/lib/server/services/ledger.test.ts index 3249728..1cc029d 100644 --- a/src/lib/server/services/ledger.test.ts +++ b/src/lib/server/services/ledger.test.ts @@ -1,14 +1,15 @@ /// -import { openDatabase } from '../db.ts'; -import { listLedger, listMonths, monthRange } from './ledger.ts'; -import { applyRulesToUncategorized, createRule } from './rules.ts'; -import { categorizeManually } from './categorization.ts'; -import type { DatabaseSync } from 'node:sqlite'; +import { openDatabase } from "../db.ts"; +import { listLedger, listMonths, monthRange } from "./ledger.ts"; +import { applyRulesToUncategorized, createRule } from "./rules.ts"; +import { categorizeManually } from "./categorization.ts"; +import type { DatabaseSync } from "node:sqlite"; -const MIGRATIONS_DIR = new URL('../../../../migrations', import.meta.url).pathname.replace( - /^\/([A-Za-z]:)/, - '$1' -); +const MIGRATIONS_DIR = new URL("../../../../migrations", import.meta.url) + .pathname.replace( + /^\/([A-Za-z]:)/, + "$1", + ); const JUNE_10 = Math.floor(Date.UTC(2026, 5, 10) / 1000); const JULY_02 = Math.floor(Date.UTC(2026, 6, 2) / 1000); @@ -17,54 +18,110 @@ function testDb(): DatabaseSync { const db = openDatabase(`${Deno.makeTempDirSync()}/t.db`, MIGRATIONS_DIR); const now = new Date().toISOString(); db.prepare( - "INSERT INTO connections (access_url, claimed_at) VALUES ('https://u:p@x/simplefin', ?)" + "INSERT INTO connections (access_url, claimed_at) VALUES ('https://u:p@x/simplefin', ?)", ).run(now); - for (const [id, state] of [ - ['chk', 'ACTIVE'], - ['ghost', 'HIDDEN'] - ]) { + for ( + const [id, state] of [ + ["chk", "ACTIVE"], + ["ghost", "HIDDEN"], + ] + ) { db.prepare( `INSERT INTO accounts (id, connection_id, name, currency, state, created_at) - VALUES (?, 1, ?, 'USD', ?, ?)` + VALUES (?, 1, ?, 'USD', ?, ?)`, ).run(id, id, state, now); } - db.prepare("INSERT INTO categories (name, kind, created_at) VALUES ('Dining','expense',?)").run(now); + db.prepare( + "INSERT INTO categories (name, kind, created_at) VALUES ('Dining','expense',?)", + ).run(now); const insert = db.prepare( `INSERT INTO transactions (account_id, sfin_id, posted, amount_cents, description, pending, category_id, removed_at, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + insert.run( + "chk", + "june-dining", + JUNE_10, + -2500, + "THAI PALACE", + 0, + 2, + null, + now, + ); + insert.run( + "chk", + "july-uncat", + JULY_02, + -1000, + "MYSTERY", + 0, + null, + null, + now, + ); + insert.run( + "chk", + "july-pending", + null, + -500, + "PENDING COFFEE", + 1, + null, + null, + now, + ); + insert.run( + "chk", + "removed", + JULY_02, + -999, + "GHOST PENDING", + 0, + null, + now, + now, + ); // soft-removed + insert.run( + "ghost", + "hidden-txn", + JULY_02, + -777, + "HIDDEN SPEND", + 0, + null, + null, + now, ); - insert.run('chk', 'june-dining', JUNE_10, -2500, 'THAI PALACE', 0, 2, null, now); - insert.run('chk', 'july-uncat', JULY_02, -1000, 'MYSTERY', 0, null, null, now); - insert.run('chk', 'july-pending', null, -500, 'PENDING COFFEE', 1, null, null, now); - insert.run('chk', 'removed', JULY_02, -999, 'GHOST PENDING', 0, null, now, now); // soft-removed - insert.run('ghost', 'hidden-txn', JULY_02, -777, 'HIDDEN SPEND', 0, null, null, now); return db; } -Deno.test('monthRange parses and rejects', () => { - const { start, end } = monthRange('2026-06'); - if (start !== Date.UTC(2026, 5, 1) / 1000 || end !== Date.UTC(2026, 6, 1) / 1000) { - throw new Error('wrong June range'); +Deno.test("monthRange parses and rejects", () => { + const { start, end } = monthRange("2026-06"); + if ( + start !== Date.UTC(2026, 5, 1) / 1000 || end !== Date.UTC(2026, 6, 1) / 1000 + ) { + throw new Error("wrong June range"); } let threw = false; try { - monthRange('junk'); + monthRange("junk"); } catch { threw = true; } - if (!threw) throw new Error('expected invalid month to throw'); + if (!threw) throw new Error("expected invalid month to throw"); }); -Deno.test('ledger excludes hidden accounts and soft-removed rows by default', () => { +Deno.test("ledger excludes hidden accounts and soft-removed rows by default", () => { const db = testDb(); const rows = listLedger(db); const ids = rows.map((r) => r.description); - if (ids.includes('HIDDEN SPEND')) throw new Error('hidden account leaked'); - if (ids.includes('GHOST PENDING')) throw new Error('soft-removed row leaked'); + if (ids.includes("HIDDEN SPEND")) throw new Error("hidden account leaked"); + if (ids.includes("GHOST PENDING")) throw new Error("soft-removed row leaked"); if (rows.length !== 3) throw new Error(`expected 3 rows, got ${rows.length}`); // pending rows sort first - if (!rows[0].pending) throw new Error('pending row should lead'); + if (!rows[0].pending) throw new Error("pending row should lead"); db.close(); }); @@ -76,158 +133,204 @@ function withImport(db: DatabaseSync): number { db .prepare( `INSERT INTO imports (account_id, filename, uploaded_at, payload, status, committed_at) - VALUES ('chk', 'chase-2026.csv', ?, 'Date,Description,Amount', 'committed', ?)` + VALUES ('chk', 'chase-2026.csv', ?, 'Date,Description,Amount', 'committed', ?)`, ) - .run(now, now).lastInsertRowid + .run(now, now).lastInsertRowid, ); db.prepare( `INSERT INTO transactions (account_id, sfin_id, posted, amount_cents, description, pending, import_id, created_at) - VALUES ('chk', 'csv:abc123', ?, -1500, 'BACKFILLED LUNCH', 0, ?, ?)` + VALUES ('chk', 'csv:abc123', ?, -1500, 'BACKFILLED LUNCH', 0, ?, ?)`, ).run(JUNE_10, importId, now); return importId; } -Deno.test('source filter separates imported from synced', () => { +Deno.test("source filter separates imported from synced", () => { const db = testDb(); withImport(db); - const imported = listLedger(db, { source: 'imported' }); - if (imported.length !== 1 || imported[0].description !== 'BACKFILLED LUNCH') { - throw new Error(`imported filter wrong: ${imported.map((r) => r.description).join(',')}`); + const imported = listLedger(db, { source: "imported" }); + if (imported.length !== 1 || imported[0].description !== "BACKFILLED LUNCH") { + throw new Error( + `imported filter wrong: ${imported.map((r) => r.description).join(",")}`, + ); } - const synced = listLedger(db, { source: 'synced' }); - if (synced.some((r) => r.description === 'BACKFILLED LUNCH')) { - throw new Error('imported row leaked into the synced filter'); + const synced = listLedger(db, { source: "synced" }); + if (synced.some((r) => r.description === "BACKFILLED LUNCH")) { + throw new Error("imported row leaked into the synced filter"); + } + if (synced.length !== 3) { + throw new Error(`expected the 3 synced rows, got ${synced.length}`); } - if (synced.length !== 3) throw new Error(`expected the 3 synced rows, got ${synced.length}`); // Unfiltered shows both — origin is an on-demand question, not a default lens. - if (listLedger(db).length !== 4) throw new Error('unfiltered should show every row'); + if (listLedger(db).length !== 4) { + throw new Error("unfiltered should show every row"); + } db.close(); }); -Deno.test('source filter composes with other filters', () => { +Deno.test("source filter composes with other filters", () => { const db = testDb(); withImport(db); - if (listLedger(db, { source: 'imported', month: '2026-06' }).length !== 1) { - throw new Error('source + month wrong'); + if (listLedger(db, { source: "imported", month: "2026-06" }).length !== 1) { + throw new Error("source + month wrong"); } - if (listLedger(db, { source: 'imported', month: '2026-07' }).length !== 0) { - throw new Error('source + month should exclude other months'); + if (listLedger(db, { source: "imported", month: "2026-07" }).length !== 0) { + throw new Error("source + month should exclude other months"); } - if (listLedger(db, { source: 'imported', accountId: 'chk' }).length !== 1) { - throw new Error('source + account wrong'); + if (listLedger(db, { source: "imported", accountId: "chk" }).length !== 1) { + throw new Error("source + account wrong"); } - if (listLedger(db, { source: 'synced', month: '2026-06' }).length !== 1) { - throw new Error('source + month should still find the synced June row'); + if (listLedger(db, { source: "synced", month: "2026-06" }).length !== 1) { + throw new Error("source + month should still find the synced June row"); } - if (listLedger(db, { source: 'imported', q: 'lunch' }).length !== 1) { - throw new Error('source + search wrong'); + if (listLedger(db, { source: "imported", q: "lunch" }).length !== 1) { + throw new Error("source + search wrong"); } - if (listLedger(db, { source: 'synced', q: 'lunch' }).length !== 0) { - throw new Error('source + search should exclude the imported match'); + if (listLedger(db, { source: "synced", q: "lunch" }).length !== 0) { + throw new Error("source + search should exclude the imported match"); } - if (listLedger(db, { source: 'imported', category: 'uncategorized' }).length !== 1) { - throw new Error('source + category wrong'); + if ( + listLedger(db, { source: "imported", category: "uncategorized" }).length !== + 1 + ) { + throw new Error("source + category wrong"); } db.close(); }); -Deno.test('ledger row carries its origin', () => { +Deno.test("ledger row carries its origin", () => { const db = testDb(); const importId = withImport(db); - const [row] = listLedger(db, { source: 'imported' }); - if (row.source !== 'imported') throw new Error('source not set'); - if (row.importId !== importId) throw new Error('importId not joined'); - if (row.importFilename !== 'chase-2026.csv') throw new Error('filename not joined'); - if (!row.importedAt) throw new Error('importedAt not joined'); + const [row] = listLedger(db, { source: "imported" }); + if (row.source !== "imported") throw new Error("source not set"); + if (row.importId !== importId) throw new Error("importId not joined"); + if (row.importFilename !== "chase-2026.csv") { + throw new Error("filename not joined"); + } + if (!row.importedAt) throw new Error("importedAt not joined"); - const synced = listLedger(db, { source: 'synced' })[0]; - if (synced.source !== 'synced') throw new Error('synced row mislabeled'); + const synced = listLedger(db, { source: "synced" })[0]; + if (synced.source !== "synced") throw new Error("synced row mislabeled"); if (synced.importId !== null || synced.importFilename !== null) { - throw new Error('synced row should carry no import origin'); + throw new Error("synced row should carry no import origin"); } db.close(); }); -Deno.test('ledger filters: month, category, uncategorized, pending, account', () => { +Deno.test("ledger filters: month, category, uncategorized, pending, account", () => { const db = testDb(); - const june = listLedger(db, { month: '2026-06' }); - if (june.length !== 1 || june[0].description !== 'THAI PALACE') - throw new Error('month filter wrong'); + const june = listLedger(db, { month: "2026-06" }); + if (june.length !== 1 || june[0].description !== "THAI PALACE") { + throw new Error("month filter wrong"); + } const dining = listLedger(db, { category: 2 }); - if (dining.length !== 1 || dining[0].categoryName !== 'Dining') - throw new Error('category filter wrong'); + if (dining.length !== 1 || dining[0].categoryName !== "Dining") { + throw new Error("category filter wrong"); + } - const uncat = listLedger(db, { category: 'uncategorized' }); - if (uncat.some((r) => r.categoryId !== null) || uncat.length !== 2) - throw new Error('uncategorized filter wrong'); + const uncat = listLedger(db, { category: "uncategorized" }); + if (uncat.some((r) => r.categoryId !== null) || uncat.length !== 2) { + throw new Error("uncategorized filter wrong"); + } - if (listLedger(db, { pending: true }).length !== 1) throw new Error('pending-only wrong'); - if (listLedger(db, { pending: false }).length !== 2) throw new Error('posted-only wrong'); - if (listLedger(db, { accountId: 'chk' }).length !== 3) throw new Error('account filter wrong'); + if (listLedger(db, { pending: true }).length !== 1) { + throw new Error("pending-only wrong"); + } + if (listLedger(db, { pending: false }).length !== 2) { + throw new Error("posted-only wrong"); + } + if (listLedger(db, { accountId: "chk" }).length !== 3) { + throw new Error("account filter wrong"); + } db.close(); }); -Deno.test('listMonths returns distinct months, newest first, excluding removed', () => { +Deno.test("listMonths returns distinct months, newest first, excluding removed", () => { const db = testDb(); const months = listMonths(db); // pending row falls into its created_at month (this month), plus 2026-07 and 2026-06 - if (months[months.length - 1] !== '2026-06') throw new Error('oldest month should be June'); - if (!months.includes('2026-07')) throw new Error('July missing'); - if (new Set(months).size !== months.length) throw new Error('months not distinct'); + if (months[months.length - 1] !== "2026-06") { + throw new Error("oldest month should be June"); + } + if (!months.includes("2026-07")) throw new Error("July missing"); + if (new Set(months).size !== months.length) { + throw new Error("months not distinct"); + } db.close(); }); -Deno.test('rule display-name overlay applies at read time and sheds on manual recategorization', () => { +Deno.test("rule display-name overlay applies at read time and sheds on manual recategorization", () => { const db = testDb(); const now = new Date().toISOString(); - db.prepare("INSERT INTO users (did, handle, created_at) VALUES ('did:plc:t','tester',?)").run(now); + db.prepare( + "INSERT INTO users (did, handle, created_at) VALUES ('did:plc:t','tester',?)", + ).run(now); createRule(db, { - matchType: 'contains', - pattern: 'ACH TRANSFER 4417', - displayName: 'Rent', + matchType: "contains", + pattern: "ACH TRANSFER 4417", + displayName: "Rent", categoryId: 2, - createdByDid: 'did:plc:t' + createdByDid: "did:plc:t", }); db.prepare( `INSERT INTO transactions (account_id, sfin_id, posted, amount_cents, description, pending, created_at) - VALUES ('chk', 'rent-1', ?, -120000, 'ACH TRANSFER 4417', 0, ?)` + VALUES ('chk', 'rent-1', ?, -120000, 'ACH TRANSFER 4417', 0, ?)`, ).run(JULY_02, now); applyRulesToUncategorized(db); - let row = listLedger(db, { q: 'rent' }).find((r) => r.description === 'ACH TRANSFER 4417'); - if (!row) throw new Error('search by displayed name should find the renamed transaction'); - if (row.displayLabel !== 'Rent') throw new Error(`overlay not applied: ${row.displayLabel}`); - if (row.description !== 'ACH TRANSFER 4417') throw new Error('raw description must stay canonical'); + let row = listLedger(db, { q: "rent" }).find((r) => + r.description === "ACH TRANSFER 4417" + ); + if (!row) { + throw new Error( + "search by displayed name should find the renamed transaction", + ); + } + if (row.displayLabel !== "Rent") { + throw new Error(`overlay not applied: ${row.displayLabel}`); + } + if (row.description !== "ACH TRANSFER 4417") { + throw new Error("raw description must stay canonical"); + } // manual recategorization sheds the overlay along with the rule's provenance - categorizeManually(db, row.id, 2, 'did:plc:t'); - row = listLedger(db).find((r) => r.description === 'ACH TRANSFER 4417'); - if (row!.displayLabel !== 'ACH TRANSFER 4417') - throw new Error('overlay should shed after manual recategorization'); - if (listLedger(db, { q: 'rent' }).some((r) => r.description === 'ACH TRANSFER 4417')) - throw new Error('shed overlay should no longer be searchable'); + categorizeManually(db, row.id, 2, "did:plc:t"); + row = listLedger(db).find((r) => r.description === "ACH TRANSFER 4417"); + if (row!.displayLabel !== "ACH TRANSFER 4417") { + throw new Error("overlay should shed after manual recategorization"); + } + if ( + listLedger(db, { q: "rent" }).some((r) => + r.description === "ACH TRANSFER 4417" + ) + ) { + throw new Error("shed overlay should no longer be searchable"); + } db.close(); }); -Deno.test('ledger free-text search matches raw fields and composes with filters', () => { +Deno.test("ledger free-text search matches raw fields and composes with filters", () => { const db = testDb(); - const thai = listLedger(db, { q: 'thai' }); - if (thai.length !== 1 || thai[0].description !== 'THAI PALACE') - throw new Error('case-insensitive description search failed'); + const thai = listLedger(db, { q: "thai" }); + if (thai.length !== 1 || thai[0].description !== "THAI PALACE") { + throw new Error("case-insensitive description search failed"); + } - if (listLedger(db, { q: 'thai', month: '2026-07' }).length !== 0) - throw new Error('search must compose with the month filter'); - if (listLedger(db, { q: 'thai', month: '2026-06' }).length !== 1) - throw new Error('search + matching month should hit'); + if (listLedger(db, { q: "thai", month: "2026-07" }).length !== 0) { + throw new Error("search must compose with the month filter"); + } + if (listLedger(db, { q: "thai", month: "2026-06" }).length !== 1) { + throw new Error("search + matching month should hit"); + } - if (listLedger(db, { q: '100%' }).length !== 0) - throw new Error('LIKE wildcards in the query must be escaped'); + if (listLedger(db, { q: "100%" }).length !== 0) { + throw new Error("LIKE wildcards in the query must be escaped"); + } db.close(); }); diff --git a/src/lib/server/services/ledger.ts b/src/lib/server/services/ledger.ts index 9f2c17a..545c630 100644 --- a/src/lib/server/services/ledger.ts +++ b/src/lib/server/services/ledger.ts @@ -1,10 +1,10 @@ -import type { DatabaseSync } from 'node:sqlite'; -import type { EventSource } from './categorization.ts'; +import type { DatabaseSync } from "node:sqlite"; +import type { EventSource } from "./categorization.ts"; export interface LedgerFilters { accountId?: string; /** number = category id, 'uncategorized' = no current category */ - category?: number | 'uncategorized'; + category?: number | "uncategorized"; /** 'YYYY-MM' */ month?: string; /** true = only pending, false = only posted, undefined = both */ @@ -13,7 +13,7 @@ export interface LedgerFilters { * Where the row came from: synced from a connection, or backfilled from a CSV. * A separate axis from `provenance`, which is about who chose the category. */ - source?: 'synced' | 'imported'; + source?: "synced" | "imported"; /** * Case-insensitive substring search over description, payee, memo, and the * effective rule-applied display name — search matches what the ledger shows. @@ -50,7 +50,7 @@ export interface LedgerRow { * within a backfilled date range every row is imported, so a per-row mark would * carry no information exactly where it is densest. */ - source: 'synced' | 'imported'; + source: "synced" | "imported"; importId: number | null; importFilename: string | null; importedAt: string | null; @@ -66,20 +66,24 @@ export function monthRange(month: string): { start: number; end: number } { return { start, end }; } -const EFFECTIVE_TS = `COALESCE(t.posted, t.transacted_at, CAST(strftime('%s', t.created_at) AS INTEGER))`; +const EFFECTIVE_TS = + `COALESCE(t.posted, t.transacted_at, CAST(strftime('%s', t.created_at) AS INTEGER))`; -export function listLedger(db: DatabaseSync, filters: LedgerFilters = {}): LedgerRow[] { +export function listLedger( + db: DatabaseSync, + filters: LedgerFilters = {}, +): LedgerRow[] { const where: string[] = ["t.removed_at IS NULL", "a.state != 'HIDDEN'"]; const params: (string | number)[] = []; if (filters.accountId) { - where.push('t.account_id = ?'); + where.push("t.account_id = ?"); params.push(filters.accountId); } - if (filters.category === 'uncategorized') { - where.push('t.category_id IS NULL'); - } else if (typeof filters.category === 'number') { - where.push('t.category_id = ?'); + if (filters.category === "uncategorized") { + where.push("t.category_id IS NULL"); + } else if (typeof filters.category === "number") { + where.push("t.category_id = ?"); params.push(filters.category); } if (filters.month) { @@ -88,19 +92,23 @@ export function listLedger(db: DatabaseSync, filters: LedgerFilters = {}): Ledge params.push(start, end); } if (filters.pending !== undefined) { - where.push('t.pending = ?'); + where.push("t.pending = ?"); params.push(filters.pending ? 1 : 0); } if (filters.source) { - where.push(filters.source === 'imported' ? 't.import_id IS NOT NULL' : 't.import_id IS NULL'); + where.push( + filters.source === "imported" + ? "t.import_id IS NOT NULL" + : "t.import_id IS NULL", + ); } if (filters.q?.trim()) { // r is the categorizing rule of the latest event (joined below), so the // overlay display name is searchable — search finds what the user sees. - const like = `%${filters.q.trim().replace(/[\\%_]/g, '\\$&')}%`; + const like = `%${filters.q.trim().replace(/[\\%_]/g, "\\$&")}%`; where.push( `(t.description LIKE ? ESCAPE '\\' OR t.payee LIKE ? ESCAPE '\\' - OR t.memo LIKE ? ESCAPE '\\' OR r.display_name LIKE ? ESCAPE '\\')` + OR t.memo LIKE ? ESCAPE '\\' OR r.display_name LIKE ? ESCAPE '\\')`, ); params.push(like, like, like, like); } @@ -124,9 +132,9 @@ export function listLedger(db: DatabaseSync, filters: LedgerFilters = {}): Ledge LEFT JOIN rules r ON r.id = e.rule_id LEFT JOIN users u ON u.did = e.actor_did LEFT JOIN imports i ON i.id = t.import_id - WHERE ${where.join(' AND ')} + WHERE ${where.join(" AND ")} ORDER BY t.pending DESC, effective_at DESC, t.id DESC - LIMIT ?` + LIMIT ?`, ) .all(...params, filters.limit ?? 500) as Record[]; @@ -144,14 +152,14 @@ export function listLedger(db: DatabaseSync, filters: LedgerFilters = {}): Ledge pending: r.pending === 1, categoryId: r.category_id as number | null, categoryName: r.category_name as string | null, - source: r.import_id == null ? ('synced' as const) : ('imported' as const), + source: r.import_id == null ? ("synced" as const) : ("imported" as const), importId: r.import_id as number | null, importFilename: r.import_filename as string | null, importedAt: r.imported_at as string | null, provenance: (r.prov_source as EventSource | null) ?? null, provenanceRulePattern: r.prov_pattern as string | null, provenanceActorHandle: r.prov_handle as string | null, - provenanceActorDid: r.prov_actor_did as string | null + provenanceActorDid: r.prov_actor_did as string | null, })); } @@ -160,7 +168,7 @@ export function listMonths(db: DatabaseSync): string[] { const rows = db .prepare( `SELECT DISTINCT strftime('%Y-%m', ${EFFECTIVE_TS}, 'unixepoch') AS month - FROM transactions t WHERE t.removed_at IS NULL ORDER BY month DESC` + FROM transactions t WHERE t.removed_at IS NULL ORDER BY month DESC`, ) .all() as { month: string }[]; return rows.map((r) => r.month); diff --git a/src/lib/server/services/normalize.test.ts b/src/lib/server/services/normalize.test.ts index 1f71a78..41f5d69 100644 --- a/src/lib/server/services/normalize.test.ts +++ b/src/lib/server/services/normalize.test.ts @@ -1,26 +1,28 @@ /// -import { normalizePayload, parseAmountToCents } from './normalize.ts'; +import { normalizePayload, parseAmountToCents } from "./normalize.ts"; -function assertEq(actual: unknown, expected: unknown, label = '') { - if (actual !== expected) throw new Error(`${label} expected ${expected}, got ${actual}`); +function assertEq(actual: unknown, expected: unknown, label = "") { + if (actual !== expected) { + throw new Error(`${label} expected ${expected}, got ${actual}`); + } } -Deno.test('parseAmountToCents handles SimpleFIN decimal strings exactly', () => { - assertEq(parseAmountToCents('123.45'), 12345); - assertEq(parseAmountToCents('-123.45'), -12345); - assertEq(parseAmountToCents('0.01'), 1); - assertEq(parseAmountToCents('-0.01'), -1); - assertEq(parseAmountToCents('1'), 100); - assertEq(parseAmountToCents('1.5'), 150); - assertEq(parseAmountToCents('1.005'), 101, 'rounds half away from zero'); - assertEq(parseAmountToCents('-1.005'), -101); - assertEq(parseAmountToCents('4222.19'), 422219, 'no float drift'); +Deno.test("parseAmountToCents handles SimpleFIN decimal strings exactly", () => { + assertEq(parseAmountToCents("123.45"), 12345); + assertEq(parseAmountToCents("-123.45"), -12345); + assertEq(parseAmountToCents("0.01"), 1); + assertEq(parseAmountToCents("-0.01"), -1); + assertEq(parseAmountToCents("1"), 100); + assertEq(parseAmountToCents("1.5"), 150); + assertEq(parseAmountToCents("1.005"), 101, "rounds half away from zero"); + assertEq(parseAmountToCents("-1.005"), -101); + assertEq(parseAmountToCents("4222.19"), 422219, "no float drift"); // provider variance that must not fail a sync - assertEq(parseAmountToCents('+12.00'), 1200, 'explicit plus sign'); - assertEq(parseAmountToCents('.50'), 50, 'bare leading dot'); - assertEq(parseAmountToCents('-.50'), -50, 'negative bare dot'); - assertEq(parseAmountToCents('1,234.56'), 123456, 'thousands separators'); - for (const bad of ['', '.', 'abc', '12.34.56', '-']) { + assertEq(parseAmountToCents("+12.00"), 1200, "explicit plus sign"); + assertEq(parseAmountToCents(".50"), 50, "bare leading dot"); + assertEq(parseAmountToCents("-.50"), -50, "negative bare dot"); + assertEq(parseAmountToCents("1,234.56"), 123456, "thousands separators"); + for (const bad of ["", ".", "abc", "12.34.56", "-"]) { let threw = false; try { parseAmountToCents(bad); @@ -31,32 +33,38 @@ Deno.test('parseAmountToCents handles SimpleFIN decimal strings exactly', () => } }); -Deno.test('normalizePayload maps accounts, transactions, errors', () => { +Deno.test("normalizePayload maps accounts, transactions, errors", () => { const payload = JSON.stringify({ - errors: ['Connection to Chase needs attention'], + errors: ["Connection to Chase needs attention"], accounts: [ { - org: { name: 'Chase', domain: 'chase.com', 'sfin-url': 'https://x' }, - id: 'act-1', - name: 'Checking', - currency: 'USD', - balance: '1500.25', - 'available-balance': '1450.00', - 'balance-date': 1750000000, + org: { name: "Chase", domain: "chase.com", "sfin-url": "https://x" }, + id: "act-1", + name: "Checking", + currency: "USD", + balance: "1500.25", + "available-balance": "1450.00", + "balance-date": 1750000000, transactions: [ { - id: 'txn-1', + id: "txn-1", posted: 1749900000, - amount: '-42.19', - description: 'PURCHASE KROGER #123 COLUMBUS OH CARD1111', - payee: 'Kroger', - memo: 'weekly shop', - extra: { memo: 'grocery' } + amount: "-42.19", + description: "PURCHASE KROGER #123 COLUMBUS OH CARD1111", + payee: "Kroger", + memo: "weekly shop", + extra: { memo: "grocery" }, + }, + { + id: "txn-2", + posted: 0, + pending: true, + amount: "-9.99", + description: "PENDING COFFEE", }, - { id: 'txn-2', posted: 0, pending: true, amount: '-9.99', description: 'PENDING COFFEE' } - ] - } - ] + ], + }, + ], }); const result = normalizePayload(payload); assertEq(result.errors.length, 1); @@ -64,14 +72,14 @@ Deno.test('normalizePayload maps accounts, transactions, errors', () => { const account = result.accounts[0]; assertEq(account.balanceCents, 150025); assertEq(account.availableBalanceCents, 145000); - assertEq(account.org.name, 'Chase'); + assertEq(account.org.name, "Chase"); const [posted, pending] = account.transactions; assertEq(posted.pending, false); assertEq(posted.amountCents, -4219); - assertEq(posted.payee, 'Kroger', 'top-level payee captured'); - assertEq(posted.memo, 'weekly shop', 'top-level memo captured'); + assertEq(posted.payee, "Kroger", "top-level payee captured"); + assertEq(posted.memo, "weekly shop", "top-level memo captured"); assertEq(posted.extra, '{"memo":"grocery"}'); assertEq(pending.pending, true); - assertEq(pending.payee, null, 'absent payee is null'); - assertEq(pending.posted, null, 'pending posted=0 becomes null'); + assertEq(pending.payee, null, "absent payee is null"); + assertEq(pending.posted, null, "pending posted=0 becomes null"); }); diff --git a/src/lib/server/services/normalize.ts b/src/lib/server/services/normalize.ts index 2c9ddef..348e851 100644 --- a/src/lib/server/services/normalize.ts +++ b/src/lib/server/services/normalize.ts @@ -46,16 +46,16 @@ export interface NormalizedPayload { * leading dot (".50") — one exotic amount must not fail a whole sync. */ export function parseAmountToCents(amount: string | number): number { - const text = String(amount).trim().replace(/,/g, ''); + const text = String(amount).trim().replace(/,/g, ""); const match = text.match(/^([+-]?)(\d*)(?:\.(\d+))?$/); if (!match || (!match[2] && !match[3])) { throw new Error(`Unparseable amount: ${JSON.stringify(amount)}`); } - const [, sign, whole = '', fracRaw = ''] = match; - const frac = (fracRaw + '00').slice(0, 2); - let cents = Number(whole || '0') * 100 + Number(frac); + const [, sign, whole = "", fracRaw = ""] = match; + const frac = (fracRaw + "00").slice(0, 2); + let cents = Number(whole || "0") * 100 + Number(frac); if (fracRaw.length > 2 && Number(fracRaw[2]) >= 5) cents += 1; - return sign === '-' ? -cents : cents; + return sign === "-" ? -cents : cents; } export function normalizePayload(payloadText: string): NormalizedPayload { @@ -67,41 +67,47 @@ export function normalizePayload(payloadText: string): NormalizedPayload { const errors = (raw.errors ?? []).map((e) => String(e)); const accounts: NormalizedAccount[] = (raw.accounts ?? []).map((account) => { const org = (account.org ?? {}) as Record; - const transactions = ((account.transactions ?? []) as Record[]).map( - (txn): NormalizedTransaction => { - const posted = typeof txn.posted === 'number' && txn.posted > 0 ? txn.posted : null; - const pending = txn.pending === true || posted === null; - const payee = typeof txn.payee === 'string' ? txn.payee.trim() : ''; - const memo = typeof txn.memo === 'string' ? txn.memo.trim() : ''; - return { - sfinId: String(txn.id), - posted: pending ? null : posted, - transactedAt: typeof txn.transacted_at === 'number' ? txn.transacted_at : null, - amountCents: parseAmountToCents(txn.amount as string), - description: String(txn.description ?? ''), - payee: payee || null, - memo: memo || null, - pending, - extra: txn.extra !== undefined ? JSON.stringify(txn.extra) : null - }; - } - ); + const transactions = + ((account.transactions ?? []) as Record[]).map( + (txn): NormalizedTransaction => { + const posted = typeof txn.posted === "number" && txn.posted > 0 + ? txn.posted + : null; + const pending = txn.pending === true || posted === null; + const payee = typeof txn.payee === "string" ? txn.payee.trim() : ""; + const memo = typeof txn.memo === "string" ? txn.memo.trim() : ""; + return { + sfinId: String(txn.id), + posted: pending ? null : posted, + transactedAt: typeof txn.transacted_at === "number" + ? txn.transacted_at + : null, + amountCents: parseAmountToCents(txn.amount as string), + description: String(txn.description ?? ""), + payee: payee || null, + memo: memo || null, + pending, + extra: txn.extra !== undefined ? JSON.stringify(txn.extra) : null, + }; + }, + ); return { id: String(account.id), org: { name: org.name != null ? String(org.name) : null, domain: org.domain != null ? String(org.domain) : null, - sfinUrl: org['sfin-url'] != null ? String(org['sfin-url']) : null + sfinUrl: org["sfin-url"] != null ? String(org["sfin-url"]) : null, }, - name: String(account.name ?? ''), - currency: String(account.currency ?? 'USD'), + name: String(account.name ?? ""), + currency: String(account.currency ?? "USD"), balanceCents: parseAmountToCents(account.balance as string), - availableBalanceCents: - account['available-balance'] != null - ? parseAmountToCents(account['available-balance'] as string) - : null, - balanceDate: typeof account['balance-date'] === 'number' ? account['balance-date'] : null, - transactions + availableBalanceCents: account["available-balance"] != null + ? parseAmountToCents(account["available-balance"] as string) + : null, + balanceDate: typeof account["balance-date"] === "number" + ? account["balance-date"] + : null, + transactions, }; }); diff --git a/src/lib/server/services/reports.test.ts b/src/lib/server/services/reports.test.ts index f053542..16e52c8 100644 --- a/src/lib/server/services/reports.test.ts +++ b/src/lib/server/services/reports.test.ts @@ -1,108 +1,138 @@ /// -import { openDatabase } from '../db.ts'; -import { monthlyReport, netWorthSeries, pendingStats } from './reports.ts'; -import type { DatabaseSync } from 'node:sqlite'; +import { openDatabase } from "../db.ts"; +import { monthlyReport, netWorthSeries, pendingStats } from "./reports.ts"; +import type { DatabaseSync } from "node:sqlite"; -const MIGRATIONS_DIR = new URL('../../../../migrations', import.meta.url).pathname.replace( - /^\/([A-Za-z]:)/, - '$1' -); +const MIGRATIONS_DIR = new URL("../../../../migrations", import.meta.url) + .pathname.replace( + /^\/([A-Za-z]:)/, + "$1", + ); const JUNE = Math.floor(Date.UTC(2026, 5, 15) / 1000); // mid June 2026 function seed(db: DatabaseSync) { const now = new Date().toISOString(); db.prepare( - "INSERT INTO connections (access_url, claimed_at) VALUES ('https://u:p@x/simplefin', ?)" + "INSERT INTO connections (access_url, claimed_at) VALUES ('https://u:p@x/simplefin', ?)", ).run(now); - for (const [id, state] of [ - ['checking', 'ACTIVE'], - ['hidden-acct', 'HIDDEN'] - ]) { + for ( + const [id, state] of [ + ["checking", "ACTIVE"], + ["hidden-acct", "HIDDEN"], + ] + ) { db.prepare( `INSERT INTO accounts (id, connection_id, name, currency, state, created_at) - VALUES (?, 1, ?, 'USD', ?, ?)` + VALUES (?, 1, ?, 'USD', ?, ?)`, ).run(id, id, state, now); } - db.prepare("INSERT INTO categories (name, kind, created_at) VALUES ('Salary','income',?)").run(now); - db.prepare("INSERT INTO categories (name, kind, created_at) VALUES ('Groceries','expense',?)").run( - now + db.prepare( + "INSERT INTO categories (name, kind, created_at) VALUES ('Salary','income',?)", + ).run(now); + db.prepare( + "INSERT INTO categories (name, kind, created_at) VALUES ('Groceries','expense',?)", + ).run( + now, ); // category ids: 1 Transfer (builtin), 2 Salary, 3 Groceries const insert = db.prepare( `INSERT INTO transactions (account_id, sfin_id, posted, amount_cents, description, pending, category_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ); - insert.run('checking', 's1', JUNE, 500000, 'PAYROLL', 0, 2, now); - insert.run('checking', 's2', JUNE, -12345, 'KROGER', 0, 3, now); - insert.run('checking', 's3', JUNE, -6789, 'MYSTERY SHOP', 0, null, now); // uncategorized - insert.run('checking', 's4', JUNE, -80000, 'CC PAYMENT', 0, 1, now); // transfer, excluded - insert.run('checking', 's5', JUNE, -11111, 'PENDING THING', 1, 3, now); // pending, excluded - insert.run('hidden-acct', 's6', JUNE, -99999, 'HIDDEN SPEND', 0, 3, now); // hidden, excluded + insert.run("checking", "s1", JUNE, 500000, "PAYROLL", 0, 2, now); + insert.run("checking", "s2", JUNE, -12345, "KROGER", 0, 3, now); + insert.run("checking", "s3", JUNE, -6789, "MYSTERY SHOP", 0, null, now); // uncategorized + insert.run("checking", "s4", JUNE, -80000, "CC PAYMENT", 0, 1, now); // transfer, excluded + insert.run("checking", "s5", JUNE, -11111, "PENDING THING", 1, 3, now); // pending, excluded + insert.run("hidden-acct", "s6", JUNE, -99999, "HIDDEN SPEND", 0, 3, now); // hidden, excluded } -Deno.test('monthly report: totals per category, transfers/pending/hidden excluded, uncategorized surfaced', () => { +Deno.test("monthly report: totals per category, transfers/pending/hidden excluded, uncategorized surfaced", () => { const db = openDatabase(`${Deno.makeTempDirSync()}/t.db`, MIGRATIONS_DIR); seed(db); - const report = monthlyReport(db, '2026-06'); - if (report.incomeTotalCents !== 500000) throw new Error(`income ${report.incomeTotalCents}`); - if (report.expenseTotalCents !== -12345) throw new Error(`expense ${report.expenseTotalCents}`); + const report = monthlyReport(db, "2026-06"); + if (report.incomeTotalCents !== 500000) { + throw new Error(`income ${report.incomeTotalCents}`); + } + if (report.expenseTotalCents !== -12345) { + throw new Error(`expense ${report.expenseTotalCents}`); + } if (report.netCents !== 487655) throw new Error(`net ${report.netCents}`); - if (report.uncategorized.count !== 1 || report.uncategorized.totalCents !== -6789) - throw new Error('uncategorized line wrong'); - const groceries = report.expense.find((c) => c.name === 'Groceries'); - if (groceries?.totalCents !== -12345) throw new Error('hidden/pending leaked into Groceries'); - if (monthlyReport(db, '2026-05').income.length !== 0) - throw new Error('other months should be empty'); + if ( + report.uncategorized.count !== 1 || + report.uncategorized.totalCents !== -6789 + ) { + throw new Error("uncategorized line wrong"); + } + const groceries = report.expense.find((c) => c.name === "Groceries"); + if (groceries?.totalCents !== -12345) { + throw new Error("hidden/pending leaked into Groceries"); + } + if (monthlyReport(db, "2026-05").income.length !== 0) { + throw new Error("other months should be empty"); + } db.close(); }); -Deno.test('net worth series carries forward and splits assets/liabilities, hidden excluded', () => { +Deno.test("net worth series carries forward and splits assets/liabilities, hidden excluded", () => { const db = openDatabase(`${Deno.makeTempDirSync()}/t.db`, MIGRATIONS_DIR); seed(db); const snap = db.prepare( - 'INSERT INTO balance_snapshots (account_id, captured_at, balance_cents) VALUES (?, ?, ?)' + "INSERT INTO balance_snapshots (account_id, captured_at, balance_cents) VALUES (?, ?, ?)", ); - snap.run('checking', '2026-06-01T06:00:00Z', 100000); - snap.run('hidden-acct', '2026-06-01T06:00:00Z', 555555); // excluded - snap.run('checking', '2026-06-03T06:00:00Z', 90000); + snap.run("checking", "2026-06-01T06:00:00Z", 100000); + snap.run("hidden-acct", "2026-06-01T06:00:00Z", 555555); // excluded + snap.run("checking", "2026-06-03T06:00:00Z", 90000); // second account appears on day 2 with a negative (liability) balance db.prepare( `INSERT INTO accounts (id, connection_id, name, currency, state, created_at) - VALUES ('card', 1, 'Card', 'USD', 'ACTIVE', ?)` + VALUES ('card', 1, 'Card', 'USD', 'ACTIVE', ?)`, ).run(new Date().toISOString()); - snap.run('card', '2026-06-02T06:00:00Z', -40000); + snap.run("card", "2026-06-02T06:00:00Z", -40000); const series = netWorthSeries(db); const byDate = Object.fromEntries(series.map((p) => [p.date, p])); - if (byDate['2026-06-01'].netCents !== 100000) throw new Error('day 1 wrong (hidden leaked?)'); - if (byDate['2026-06-02'].netCents !== 60000) throw new Error('day 2 should include card'); - if (byDate['2026-06-03'].netCents !== 50000) - throw new Error('day 3 should carry card forward with new checking balance'); - if (byDate['2026-06-03'].liabilitiesCents !== -40000) throw new Error('liability split wrong'); + if (byDate["2026-06-01"].netCents !== 100000) { + throw new Error("day 1 wrong (hidden leaked?)"); + } + if (byDate["2026-06-02"].netCents !== 60000) { + throw new Error("day 2 should include card"); + } + if (byDate["2026-06-03"].netCents !== 50000) { + throw new Error( + "day 3 should carry card forward with new checking balance", + ); + } + if (byDate["2026-06-03"].liabilitiesCents !== -40000) { + throw new Error("liability split wrong"); + } db.close(); }); -Deno.test('pendingStats: current-month pending count and sum, hidden/removed excluded', () => { +Deno.test("pendingStats: current-month pending count and sum, hidden/removed excluded", () => { const db = openDatabase(`${Deno.makeTempDirSync()}/t.db`, MIGRATIONS_DIR); seed(db); const now = new Date().toISOString(); // a hidden-account pending and a soft-removed pending must not count db.prepare( `INSERT INTO transactions (account_id, sfin_id, posted, amount_cents, description, pending, category_id, removed_at, created_at) - VALUES ('hidden-acct', 'hp', ?, -5000, 'HIDDEN PENDING', 1, NULL, NULL, ?)` + VALUES ('hidden-acct', 'hp', ?, -5000, 'HIDDEN PENDING', 1, NULL, NULL, ?)`, ).run(JUNE, now); db.prepare( `INSERT INTO transactions (account_id, sfin_id, posted, amount_cents, description, pending, category_id, removed_at, created_at) - VALUES ('checking', 'rp', ?, -4000, 'REMOVED PENDING', 1, NULL, ?, ?)` + VALUES ('checking', 'rp', ?, -4000, 'REMOVED PENDING', 1, NULL, ?, ?)`, ).run(JUNE, now, now); - const june = pendingStats(db, '2026-06'); - if (june.count !== 1 || june.totalCents !== -11111) + const june = pendingStats(db, "2026-06"); + if (june.count !== 1 || june.totalCents !== -11111) { throw new Error(`june pending wrong: ${JSON.stringify(june)}`); + } - const may = pendingStats(db, '2026-05'); - if (may.count !== 0 || may.totalCents !== 0) throw new Error('empty month should be zero'); + const may = pendingStats(db, "2026-05"); + if (may.count !== 0 || may.totalCents !== 0) { + throw new Error("empty month should be zero"); + } db.close(); }); diff --git a/src/lib/server/services/reports.ts b/src/lib/server/services/reports.ts index 45af0fedf9e7f138449683eb84071731dc3bf10c..ac242dd2b65d97246f7bd81af8934ede187aa504 100644 GIT binary patch delta 276 zcmbQBwoYw=60cHTeoCrUabZqoNvhIBeOX>5J^h^2l=Rdhy^><3iIFOTN_zUqi6yD& z`9+zj#bCicaV9R#iOcpdadB==W|U=N)Y;s?>dDBcGx;Q&IfqhaUUGhJs?uZ*b`wse z)QW=Cyy8@)&93Z%AQhH8vYU%JY8V-HHoxceVq(2adJ-15pZCv z-MmWR9iuN7Cnsl0Vo9o%LP}z#4v3eRTH>6VS5j=HkXT%tT2ic_t&o$Mn3S25S(2Gr r3|0b>Kv4x2pL|nDRu;sn)wJf~tmWb?N-ZfZ%2Oy#1^LXHtCkA@W}Hz4 delta 238 zcmZ3dHbHHI60dq*eoCrUabZqoNvirpeOX?0J^h^2l=Rdhy^><}iIFOT>U#Rgi6yD& z`9+zj#bCicaYluS%l9xUY))mAW!c=!>d83yESoupdS+g7er~GzWFB@CPW9A^g4Dd? zRQ1iC?1GGw9e89nS8&uYZvM*Y#kARh=M&RrV}3`*$rS<)n|BMmVdPOrNi0dVQb -import { openDatabase } from '../db.ts'; +import { openDatabase } from "../db.ts"; import { applyRulesToUncategorized, createRule, findWinningRule, probeRule, + type Rule, ruleMatchHealth, - type Rule -} from './rules.ts'; -import { categorizeManually } from './categorization.ts'; -import type { DatabaseSync } from 'node:sqlite'; +} from "./rules.ts"; +import { categorizeManually } from "./categorization.ts"; +import type { DatabaseSync } from "node:sqlite"; -const MIGRATIONS_DIR = new URL('../../../../migrations', import.meta.url).pathname.replace( - /^\/([A-Za-z]:)/, - '$1' -); +const MIGRATIONS_DIR = new URL("../../../../migrations", import.meta.url) + .pathname.replace( + /^\/([A-Za-z]:)/, + "$1", + ); function rule(partial: Partial & { pattern: string }): Rule { return { id: 1, - matchType: 'contains', + matchType: "contains", amountCents: null, displayName: null, categoryId: 1, - createdByDid: 'did:plc:t', + createdByDid: "did:plc:t", active: true, - createdAt: '2026-01-01', - ...partial + createdAt: "2026-01-01", + ...partial, }; } -Deno.test('precedence: exact beats contains, longer beats shorter, newer beats older', () => { - const contains = rule({ id: 1, pattern: 'AMAZON' }); - const longer = rule({ id: 2, pattern: 'AMAZON PRIME' }); - const exact = rule({ id: 3, matchType: 'exact', pattern: 'amazon prime *2k4l' }); - const newerSameLength = rule({ id: 4, pattern: 'aMaZoN' }); +Deno.test("precedence: exact beats contains, longer beats shorter, newer beats older", () => { + const contains = rule({ id: 1, pattern: "AMAZON" }); + const longer = rule({ id: 2, pattern: "AMAZON PRIME" }); + const exact = rule({ + id: 3, + matchType: "exact", + pattern: "amazon prime *2k4l", + }); + const newerSameLength = rule({ id: 4, pattern: "aMaZoN" }); - const target = { description: 'AMAZON PRIME *2K4L' }; - if (findWinningRule([contains, longer], target)?.id !== 2) - throw new Error('longer pattern should win'); - if (findWinningRule([contains, longer, exact], target)?.id !== 3) - throw new Error('exact should beat contains'); - if (findWinningRule([contains, newerSameLength], target)?.id !== 4) - throw new Error('newer rule should win the tie'); - if (findWinningRule([contains], { description: 'ETSY ORDER' }) !== null) - throw new Error('non-matching description should return null'); + const target = { description: "AMAZON PRIME *2K4L" }; + if (findWinningRule([contains, longer], target)?.id !== 2) { + throw new Error("longer pattern should win"); + } + if (findWinningRule([contains, longer, exact], target)?.id !== 3) { + throw new Error("exact should beat contains"); + } + if (findWinningRule([contains, newerSameLength], target)?.id !== 4) { + throw new Error("newer rule should win the tie"); + } + if (findWinningRule([contains], { description: "ETSY ORDER" }) !== null) { + throw new Error("non-matching description should return null"); + } }); -Deno.test('rules match the provider payee or memo when the description is terse', () => { - const kroger = rule({ id: 1, pattern: 'KROGER' }); - if (findWinningRule([kroger], { description: 'Card Purchase', payee: 'Kroger Columbus' })?.id !== 1) - throw new Error('payee match should fire the rule'); +Deno.test("rules match the provider payee or memo when the description is terse", () => { + const kroger = rule({ id: 1, pattern: "KROGER" }); + if ( + findWinningRule([kroger], { + description: "Card Purchase", + payee: "Kroger Columbus", + })?.id !== 1 + ) { + throw new Error("payee match should fire the rule"); + } if ( - findWinningRule([kroger], { description: 'Card Purchase', memo: 'KROGER #123 weekly shop' }) + findWinningRule([kroger], { + description: "Card Purchase", + memo: "KROGER #123 weekly shop", + }) ?.id !== 1 - ) - throw new Error('memo match should fire the rule'); - if (findWinningRule([kroger], { description: 'Card Purchase' }) !== null) - throw new Error('no payee/memo, terse description: rule must not fire'); - const exactPayee = rule({ id: 2, matchType: 'exact', pattern: 'kroger' }); - if (findWinningRule([exactPayee], { description: 'Card Purchase', payee: 'Kroger' })?.id !== 2) - throw new Error('exact match should apply to payee too'); - if (findWinningRule([exactPayee], { description: 'Card Purchase', memo: 'kroger' }) === null) - throw new Error('exact match should apply to memo too'); + ) { + throw new Error("memo match should fire the rule"); + } + if (findWinningRule([kroger], { description: "Card Purchase" }) !== null) { + throw new Error("no payee/memo, terse description: rule must not fire"); + } + const exactPayee = rule({ id: 2, matchType: "exact", pattern: "kroger" }); + if ( + findWinningRule([exactPayee], { + description: "Card Purchase", + payee: "Kroger", + })?.id !== 2 + ) { + throw new Error("exact match should apply to payee too"); + } + if ( + findWinningRule([exactPayee], { + description: "Card Purchase", + memo: "kroger", + }) === null + ) { + throw new Error("exact match should apply to memo too"); + } }); -Deno.test('amount conjunct: narrows a text match, never fires alone', () => { - const netflix = rule({ id: 1, pattern: 'NETFLIX', amountCents: -1549 }); +Deno.test("amount conjunct: narrows a text match, never fires alone", () => { + const netflix = rule({ id: 1, pattern: "NETFLIX", amountCents: -1549 }); - if (findWinningRule([netflix], { description: 'NETFLIX.COM', amountCents: -1549 })?.id !== 1) - throw new Error('text + amount should fire'); - if (findWinningRule([netflix], { description: 'NETFLIX.COM', amountCents: -1649 }) !== null) - throw new Error('amount mismatch must block the rule'); - if (findWinningRule([netflix], { description: 'SPOTIFY', amountCents: -1549 }) !== null) - throw new Error('amount alone must never fire a rule'); + if ( + findWinningRule([netflix], { + description: "NETFLIX.COM", + amountCents: -1549, + })?.id !== 1 + ) { + throw new Error("text + amount should fire"); + } + if ( + findWinningRule([netflix], { + description: "NETFLIX.COM", + amountCents: -1649, + }) !== null + ) { + throw new Error("amount mismatch must block the rule"); + } + if ( + findWinningRule([netflix], { + description: "SPOTIFY", + amountCents: -1549, + }) !== null + ) { + throw new Error("amount alone must never fire a rule"); + } }); -Deno.test('precedence: amount-constrained beats unconstrained, even exact', () => { - const exactNoAmount = rule({ id: 1, matchType: 'exact', pattern: 'netflix.com 866-579-7172' }); - const containsWithAmount = rule({ id: 2, pattern: 'NETFLIX', amountCents: -1549 }); +Deno.test("precedence: amount-constrained beats unconstrained, even exact", () => { + const exactNoAmount = rule({ + id: 1, + matchType: "exact", + pattern: "netflix.com 866-579-7172", + }); + const containsWithAmount = rule({ + id: 2, + pattern: "NETFLIX", + amountCents: -1549, + }); - const target = { description: 'NETFLIX.COM 866-579-7172', amountCents: -1549 }; - if (findWinningRule([exactNoAmount, containsWithAmount], target)?.id !== 2) - throw new Error('amount-constrained rule should outrank exact text-only rule'); + const target = { + description: "NETFLIX.COM 866-579-7172", + amountCents: -1549, + }; + if (findWinningRule([exactNoAmount, containsWithAmount], target)?.id !== 2) { + throw new Error( + "amount-constrained rule should outrank exact text-only rule", + ); + } // among amount-constrained rules the old tiebreaks still apply const exactWithAmount = rule({ id: 3, - matchType: 'exact', - pattern: 'netflix.com 866-579-7172', - amountCents: -1549 + matchType: "exact", + pattern: "netflix.com 866-579-7172", + amountCents: -1549, }); - if (findWinningRule([containsWithAmount, exactWithAmount], target)?.id !== 3) - throw new Error('exact should beat contains among amount-constrained rules'); + if ( + findWinningRule([containsWithAmount, exactWithAmount], target)?.id !== 3 + ) { + throw new Error( + "exact should beat contains among amount-constrained rules", + ); + } }); function testDb(): DatabaseSync { const db = openDatabase(`${Deno.makeTempDirSync()}/test.db`, MIGRATIONS_DIR); const now = new Date().toISOString(); - db.prepare("INSERT INTO users (did, handle, created_at) VALUES ('did:plc:t','tester',?)").run(now); db.prepare( - "INSERT INTO connections (access_url, claimed_at) VALUES ('https://u:p@x/simplefin',?)" + "INSERT INTO users (did, handle, created_at) VALUES ('did:plc:t','tester',?)", + ).run(now); + db.prepare( + "INSERT INTO connections (access_url, claimed_at) VALUES ('https://u:p@x/simplefin',?)", ).run(now); db.prepare( `INSERT INTO accounts (id, connection_id, name, currency, state, created_at) - VALUES ('act-1', 1, 'Checking', 'USD', 'ACTIVE', ?)` + VALUES ('act-1', 1, 'Checking', 'USD', 'ACTIVE', ?)`, ).run(now); - db.prepare("INSERT INTO categories (name, kind, created_at) VALUES ('Groceries','expense',?)").run( - now + db.prepare( + "INSERT INTO categories (name, kind, created_at) VALUES ('Groceries','expense',?)", + ).run( + now, ); return db; } -function insertTxn(db: DatabaseSync, sfinId: string, description: string, amountCents = -1000): number { +function insertTxn( + db: DatabaseSync, + sfinId: string, + description: string, + amountCents = -1000, +): number { const result = db .prepare( `INSERT INTO transactions (account_id, sfin_id, posted, amount_cents, description, pending, created_at) - VALUES ('act-1', ?, 1750000000, ?, ?, 0, ?)` + VALUES ('act-1', ?, 1750000000, ?, ?, 0, ?)`, ) .run(sfinId, amountCents, description, new Date().toISOString()); return Number(result.lastInsertRowid); } -Deno.test('createRule requires a text pattern', () => { +Deno.test("createRule requires a text pattern", () => { const db = testDb(); let threw = false; try { - createRule(db, { matchType: 'contains', pattern: ' ', categoryId: 2, createdByDid: 'did:plc:t' }); + createRule(db, { + matchType: "contains", + pattern: " ", + categoryId: 2, + createdByDid: "did:plc:t", + }); } catch { threw = true; } - if (!threw) throw new Error('empty pattern should be rejected'); + if (!threw) throw new Error("empty pattern should be rejected"); db.close(); }); -Deno.test('rules fire on uncategorized transactions and record the winning rule id', () => { +Deno.test("rules fire on uncategorized transactions and record the winning rule id", () => { const db = testDb(); const categoryId = 2; // after built-in Transfer (1) const created = createRule(db, { - matchType: 'contains', - pattern: 'KROGER', + matchType: "contains", + pattern: "KROGER", categoryId, - createdByDid: 'did:plc:t' + createdByDid: "did:plc:t", }); - insertTxn(db, 't1', 'KROGER #123 COLUMBUS'); - insertTxn(db, 't2', 'SHELL OIL'); + insertTxn(db, "t1", "KROGER #123 COLUMBUS"); + insertTxn(db, "t2", "SHELL OIL"); const applied = applyRulesToUncategorized(db); if (applied !== 1) throw new Error(`expected 1 applied, got ${applied}`); const event = db - .prepare('SELECT source, rule_id FROM categorization_events') + .prepare("SELECT source, rule_id FROM categorization_events") .get() as { source: string; rule_id: number }; - if (event.source !== 'rule' || event.rule_id !== created.id) - throw new Error('event should record the firing rule'); + if (event.source !== "rule" || event.rule_id !== created.id) { + throw new Error("event should record the firing rule"); + } db.close(); }); -Deno.test('amount-constrained rule skips same-text transactions at other amounts', () => { +Deno.test("amount-constrained rule skips same-text transactions at other amounts", () => { const db = testDb(); createRule(db, { - matchType: 'contains', - pattern: 'NETFLIX', + matchType: "contains", + pattern: "NETFLIX", amountCents: -1549, categoryId: 2, - createdByDid: 'did:plc:t' + createdByDid: "did:plc:t", }); - const hit = insertTxn(db, 'n1', 'NETFLIX.COM', -1549); - insertTxn(db, 'n2', 'NETFLIX.COM', -1649); // price hike: must stay uncategorized + const hit = insertTxn(db, "n1", "NETFLIX.COM", -1549); + insertTxn(db, "n2", "NETFLIX.COM", -1649); // price hike: must stay uncategorized const applied = applyRulesToUncategorized(db); if (applied !== 1) throw new Error(`expected 1 applied, got ${applied}`); const categorized = db - .prepare('SELECT id FROM transactions WHERE category_id IS NOT NULL') + .prepare("SELECT id FROM transactions WHERE category_id IS NOT NULL") .all() as { id: number }[]; - if (categorized.length !== 1 || categorized[0].id !== hit) - throw new Error('only the exact-amount transaction should be categorized'); + if (categorized.length !== 1 || categorized[0].id !== hit) { + throw new Error("only the exact-amount transaction should be categorized"); + } db.close(); }); -Deno.test('rules never overwrite a manual decision, including manual uncategorize', () => { +Deno.test("rules never overwrite a manual decision, including manual uncategorize", () => { const db = testDb(); createRule(db, { - matchType: 'contains', - pattern: 'KROGER', + matchType: "contains", + pattern: "KROGER", categoryId: 2, - createdByDid: 'did:plc:t' + createdByDid: "did:plc:t", }); - const txnId = insertTxn(db, 't1', 'KROGER #123'); + const txnId = insertTxn(db, "t1", "KROGER #123"); // manual categorization to a different category - db.prepare("INSERT INTO categories (name, kind, created_at) VALUES ('Dining','expense',?)").run( - new Date().toISOString() + db.prepare( + "INSERT INTO categories (name, kind, created_at) VALUES ('Dining','expense',?)", + ).run( + new Date().toISOString(), ); - categorizeManually(db, txnId, 3, 'did:plc:t'); + categorizeManually(db, txnId, 3, "did:plc:t"); applyRulesToUncategorized(db); - let cached = (db.prepare('SELECT category_id FROM transactions WHERE id = ?').get(txnId) as { - category_id: number; - }).category_id; - if (cached !== 3) throw new Error('rule overwrote manual categorization'); + let cached = + (db.prepare("SELECT category_id FROM transactions WHERE id = ?").get( + txnId, + ) as { + category_id: number; + }).category_id; + if (cached !== 3) throw new Error("rule overwrote manual categorization"); // manual uncategorize: latest event is manual with NULL → rules stay away - categorizeManually(db, txnId, null, 'did:plc:t'); + categorizeManually(db, txnId, null, "did:plc:t"); applyRulesToUncategorized(db); - cached = (db.prepare('SELECT category_id FROM transactions WHERE id = ?').get(txnId) as { + cached = (db.prepare("SELECT category_id FROM transactions WHERE id = ?").get( + txnId, + ) as { category_id: number | null; }).category_id as number; - if (cached !== null) throw new Error('rule overrode a manual uncategorize'); + if (cached !== null) throw new Error("rule overrode a manual uncategorize"); db.close(); }); -Deno.test('probeRule labels hits by current status and mutates nothing', () => { +Deno.test("probeRule labels hits by current status and mutates nothing", () => { const db = testDb(); const created = createRule(db, { - matchType: 'contains', - pattern: 'KROGER', + matchType: "contains", + pattern: "KROGER", categoryId: 2, - createdByDid: 'did:plc:t' + createdByDid: "did:plc:t", }); - const ruled = insertTxn(db, 'p1', 'KROGER #1'); - const manual = insertTxn(db, 'p2', 'KROGER #2'); - const untouched = insertTxn(db, 'p3', 'KROGER #3'); - insertTxn(db, 'p4', 'SHELL OIL'); + const ruled = insertTxn(db, "p1", "KROGER #1"); + const manual = insertTxn(db, "p2", "KROGER #2"); + const untouched = insertTxn(db, "p3", "KROGER #3"); + insertTxn(db, "p4", "SHELL OIL"); // categorize p1 via the rule, p2 manually, leave p3 uncategorized applyRulesToUncategorized(db); // fires on all three KROGERs — reset p2, p3 by hand below - categorizeManually(db, manual, 2, 'did:plc:t'); + categorizeManually(db, manual, 2, "did:plc:t"); // p3: manually uncategorize then re-insert scenario is overkill; instead re-check statuses as-is: // p1/p3 latest events are rule events, p2 manual. - const eventsBefore = (db.prepare('SELECT COUNT(*) AS n FROM categorization_events').get() as { - n: number; - }).n; - const hits = probeRule(db, { matchType: 'contains', pattern: 'KROGER' }, created.id); - const eventsAfter = (db.prepare('SELECT COUNT(*) AS n FROM categorization_events').get() as { - n: number; - }).n; + const eventsBefore = + (db.prepare("SELECT COUNT(*) AS n FROM categorization_events").get() as { + n: number; + }).n; + const hits = probeRule( + db, + { matchType: "contains", pattern: "KROGER" }, + created.id, + ); + const eventsAfter = + (db.prepare("SELECT COUNT(*) AS n FROM categorization_events").get() as { + n: number; + }).n; - if (eventsBefore !== eventsAfter) throw new Error('probe must not append events'); + if (eventsBefore !== eventsAfter) { + throw new Error("probe must not append events"); + } if (hits.length !== 3) throw new Error(`expected 3 hits, got ${hits.length}`); const byId = new Map(hits.map((h) => [h.transactionId, h.status])); - if (byId.get(ruled) !== 'this-rule') throw new Error('rule-categorized hit mislabeled'); - if (byId.get(manual) !== 'manual') throw new Error('manually categorized hit mislabeled'); - if (byId.get(untouched) !== 'this-rule') throw new Error('rule-fired hit mislabeled'); + if (byId.get(ruled) !== "this-rule") { + throw new Error("rule-categorized hit mislabeled"); + } + if (byId.get(manual) !== "manual") { + throw new Error("manually categorized hit mislabeled"); + } + if (byId.get(untouched) !== "this-rule") { + throw new Error("rule-fired hit mislabeled"); + } db.close(); }); -Deno.test('probeRule reports uncategorized and other-rule statuses', () => { +Deno.test("probeRule reports uncategorized and other-rule statuses", () => { const db = testDb(); const other = createRule(db, { - matchType: 'contains', - pattern: 'KROGER FUEL', + matchType: "contains", + pattern: "KROGER FUEL", categoryId: 2, - createdByDid: 'did:plc:t' + createdByDid: "did:plc:t", }); - const fueled = insertTxn(db, 'q1', 'KROGER FUEL #9'); - const plain = insertTxn(db, 'q2', 'KROGER #1'); + const fueled = insertTxn(db, "q1", "KROGER FUEL #9"); + const plain = insertTxn(db, "q2", "KROGER #1"); applyRulesToUncategorized(db, { ruleIds: [other.id] }); - const hits = probeRule(db, { matchType: 'contains', pattern: 'KROGER' }, 999); + const hits = probeRule(db, { matchType: "contains", pattern: "KROGER" }, 999); const byId = new Map(hits.map((h) => [h.transactionId, h.status])); - if (byId.get(fueled) !== 'other-rule') throw new Error('other-rule hit mislabeled'); - if (byId.get(plain) !== 'uncategorized') throw new Error('uncategorized hit mislabeled'); + if (byId.get(fueled) !== "other-rule") { + throw new Error("other-rule hit mislabeled"); + } + if (byId.get(plain) !== "uncategorized") { + throw new Error("uncategorized hit mislabeled"); + } db.close(); }); -Deno.test('ruleMatchHealth surfaces last fire and pattern-hit/amount-miss drift', () => { +Deno.test("ruleMatchHealth surfaces last fire and pattern-hit/amount-miss drift", () => { const db = testDb(); const netflix = createRule(db, { - matchType: 'contains', - pattern: 'NETFLIX', + matchType: "contains", + pattern: "NETFLIX", amountCents: -1549, categoryId: 2, - createdByDid: 'did:plc:t' + createdByDid: "did:plc:t", }); - insertTxn(db, 'h1', 'NETFLIX.COM', -1549); - insertTxn(db, 'h2', 'NETFLIX.COM', -1649); // price change + insertTxn(db, "h1", "NETFLIX.COM", -1549); + insertTxn(db, "h2", "NETFLIX.COM", -1649); // price change applyRulesToUncategorized(db); const health = ruleMatchHealth(db, netflix); - if (!health.lastFiredAt) throw new Error('lastFiredAt missing after a fire'); - if (health.firesByMonth.length !== 1 || health.firesByMonth[0].fires !== 1) - throw new Error('firesByMonth wrong'); + if (!health.lastFiredAt) throw new Error("lastFiredAt missing after a fire"); + if (health.firesByMonth.length !== 1 || health.firesByMonth[0].fires !== 1) { + throw new Error("firesByMonth wrong"); + } if ( health.patternHitAmountMiss.length !== 1 || health.patternHitAmountMiss[0].amountCents !== -1649 - ) - throw new Error('amount drift not surfaced'); + ) { + throw new Error("amount drift not surfaced"); + } const neverFired = createRule(db, { - matchType: 'contains', - pattern: 'HULU', + matchType: "contains", + pattern: "HULU", categoryId: 2, - createdByDid: 'did:plc:t' + createdByDid: "did:plc:t", }); const idle = ruleMatchHealth(db, neverFired); - if (idle.lastFiredAt !== null || idle.firesByMonth.length !== 0) - throw new Error('never-fired rule should report null health'); + if (idle.lastFiredAt !== null || idle.firesByMonth.length !== 0) { + throw new Error("never-fired rule should report null health"); + } db.close(); }); diff --git a/src/lib/server/services/rules.ts b/src/lib/server/services/rules.ts index 34e87d3..3396169 100644 --- a/src/lib/server/services/rules.ts +++ b/src/lib/server/services/rules.ts @@ -1,9 +1,9 @@ -import type { DatabaseSync } from 'node:sqlite'; -import { appendCategorizationEvent } from './categorization.ts'; +import type { DatabaseSync } from "node:sqlite"; +import { appendCategorizationEvent } from "./categorization.ts"; export interface Rule { id: number; - matchType: 'exact' | 'contains'; + matchType: "exact" | "contains"; pattern: string; /** Exact-amount conjunct in signed integer cents; null = text-only rule. */ amountCents: number | null; @@ -26,22 +26,25 @@ export interface MatchTarget { function mapRule(r: Record): Rule { return { id: r.id as number, - matchType: r.match_type as 'exact' | 'contains', + matchType: r.match_type as "exact" | "contains", pattern: r.pattern as string, amountCents: r.amount_cents as number | null, displayName: r.display_name as string | null, categoryId: r.category_id as number, createdByDid: r.created_by_did as string, active: r.active === 1, - createdAt: r.created_at as string + createdAt: r.created_at as string, }; } -export function listRules(db: DatabaseSync, options: { activeOnly?: boolean } = {}): Rule[] { +export function listRules( + db: DatabaseSync, + options: { activeOnly?: boolean } = {}, +): Rule[] { const rows = db .prepare( `SELECT id, match_type, pattern, amount_cents, display_name, category_id, created_by_did, active, created_at - FROM rules ${options.activeOnly ? 'WHERE active = 1' : ''} ORDER BY id` + FROM rules ${options.activeOnly ? "WHERE active = 1" : ""} ORDER BY id`, ) .all() as Record[]; return rows.map(mapRule); @@ -51,7 +54,7 @@ export function getRule(db: DatabaseSync, id: number): Rule | null { const row = db .prepare( `SELECT id, match_type, pattern, amount_cents, display_name, category_id, created_by_did, active, created_at - FROM rules WHERE id = ?` + FROM rules WHERE id = ?`, ) .get(id) as Record | undefined; return row ? mapRule(row) : null; @@ -60,25 +63,25 @@ export function getRule(db: DatabaseSync, id: number): Rule | null { export function createRule( db: DatabaseSync, input: { - matchType: 'exact' | 'contains'; + matchType: "exact" | "contains"; pattern: string; amountCents?: number | null; displayName?: string | null; categoryId: number; createdByDid: string; - } + }, ): Rule { const pattern = input.pattern.trim(); // Amount-only rules are not permitted: a text pattern keeps every rule // human-readable and avoids accidental broad matches on common amounts. - if (!pattern) throw new Error('A rule requires a text pattern.'); + if (!pattern) throw new Error("A rule requires a text pattern."); const displayName = input.displayName?.trim() || null; const amountCents = input.amountCents ?? null; const createdAt = new Date().toISOString(); const result = db .prepare( `INSERT INTO rules (match_type, pattern, amount_cents, display_name, category_id, created_by_did, active, created_at) - VALUES (?, ?, ?, ?, ?, ?, 1, ?)` + VALUES (?, ?, ?, ?, ?, ?, 1, ?)`, ) .run( input.matchType, @@ -87,7 +90,7 @@ export function createRule( displayName, input.categoryId, input.createdByDid, - createdAt + createdAt, ); return { id: Number(result.lastInsertRowid), @@ -98,12 +101,19 @@ export function createRule( categoryId: input.categoryId, createdByDid: input.createdByDid, active: true, - createdAt + createdAt, }; } -export function setRuleActive(db: DatabaseSync, ruleId: number, active: boolean): void { - db.prepare('UPDATE rules SET active = ? WHERE id = ?').run(active ? 1 : 0, ruleId); +export function setRuleActive( + db: DatabaseSync, + ruleId: number, + active: boolean, +): void { + db.prepare("UPDATE rules SET active = ? WHERE id = ?").run( + active ? 1 : 0, + ruleId, + ); } /** @@ -114,7 +124,9 @@ function textMatches(rule: Rule, target: MatchTarget): boolean { const pattern = rule.pattern.toLowerCase(); const hit = (text: string | null | undefined) => text != null && - (rule.matchType === 'exact' ? text.toLowerCase() === pattern : text.toLowerCase().includes(pattern)); + (rule.matchType === "exact" + ? text.toLowerCase() === pattern + : text.toLowerCase().includes(pattern)); return hit(target.description) || hit(target.payee) || hit(target.memo); } @@ -124,7 +136,9 @@ function textMatches(rule: Rule, target: MatchTarget): boolean { */ export function matches(rule: Rule, target: MatchTarget): boolean { if (!textMatches(rule, target)) return false; - if (rule.amountCents != null && target.amountCents !== rule.amountCents) return false; + if (rule.amountCents != null && target.amountCents !== rule.amountCents) { + return false; + } return true; } @@ -134,7 +148,10 @@ export function matches(rule: Rule, target: MatchTarget): boolean { * contains, then longer pattern beats shorter, then newer rule (higher id) * beats older. No manual ordering. */ -export function findWinningRule(rules: Rule[], target: MatchTarget): Rule | null { +export function findWinningRule( + rules: Rule[], + target: MatchTarget, +): Rule | null { let winner: Rule | null = null; for (const rule of rules) { if (!matches(rule, target)) continue; @@ -142,14 +159,17 @@ export function findWinningRule(rules: Rule[], target: MatchTarget): Rule | null winner = rule; continue; } - const amountness = - Number(rule.amountCents != null) - Number(winner.amountCents != null); - const exactness = Number(rule.matchType === 'exact') - Number(winner.matchType === 'exact'); + const amountness = Number(rule.amountCents != null) - + Number(winner.amountCents != null); + const exactness = Number(rule.matchType === "exact") - + Number(winner.matchType === "exact"); const length = rule.pattern.length - winner.pattern.length; if ( amountness > 0 || (amountness === 0 && - (exactness > 0 || (exactness === 0 && (length > 0 || (length === 0 && rule.id > winner.id))))) + (exactness > 0 || + (exactness === 0 && + (length > 0 || (length === 0 && rule.id > winner.id))))) ) { winner = rule; } @@ -167,10 +187,12 @@ export function findWinningRule(rules: Rule[], target: MatchTarget): Rule | null */ export function applyRulesToUncategorized( db: DatabaseSync, - options: { ruleIds?: number[] } = {} + options: { ruleIds?: number[] } = {}, ): number { let rules = listRules(db, { activeOnly: true }); - if (options.ruleIds) rules = rules.filter((r) => options.ruleIds!.includes(r.id)); + if (options.ruleIds) { + rules = rules.filter((r) => options.ruleIds!.includes(r.id)); + } if (rules.length === 0) return 0; // A transaction whose latest event is manual is skipped even when its @@ -183,15 +205,15 @@ export function applyRulesToUncategorized( SELECT 1 FROM categorization_events e WHERE e.transaction_id = t.id AND e.source = 'manual' AND e.id = (SELECT MAX(id) FROM categorization_events WHERE transaction_id = t.id) - )` + )`, ) .all() as { - id: number; - description: string; - payee: string | null; - memo: string | null; - amount_cents: number; - }[]; + id: number; + description: string; + payee: string | null; + memo: string | null; + amount_cents: number; + }[]; let count = 0; for (const txn of candidates) { @@ -199,14 +221,14 @@ export function applyRulesToUncategorized( description: txn.description, payee: txn.payee, memo: txn.memo, - amountCents: txn.amount_cents + amountCents: txn.amount_cents, }); if (!winner) continue; appendCategorizationEvent(db, { transactionId: txn.id, categoryId: winner.categoryId, - source: 'rule', - ruleId: winner.id + source: "rule", + ruleId: winner.id, }); count++; } @@ -215,7 +237,7 @@ export function applyRulesToUncategorized( /** A prospective rule shape for probing, before any row exists. */ export interface RuleProbe { - matchType: 'exact' | 'contains'; + matchType: "exact" | "contains"; pattern: string; amountCents?: number | null; } @@ -228,9 +250,9 @@ function probeAsRule(probe: RuleProbe): Rule { amountCents: probe.amountCents ?? null, displayName: null, categoryId: 0, - createdByDid: '', + createdByDid: "", active: true, - createdAt: '' + createdAt: "", }; } @@ -239,15 +261,20 @@ export function countRuleMatches(db: DatabaseSync, probe: RuleProbe): number { const rule = probeAsRule(probe); const rows = db .prepare( - 'SELECT description, payee, memo, amount_cents FROM transactions WHERE category_id IS NULL AND removed_at IS NULL' + "SELECT description, payee, memo, amount_cents FROM transactions WHERE category_id IS NULL AND removed_at IS NULL", ) - .all() as { description: string; payee: string | null; memo: string | null; amount_cents: number }[]; + .all() as { + description: string; + payee: string | null; + memo: string | null; + amount_cents: number; + }[]; return rows.filter((r) => matches(rule, { description: r.description, payee: r.payee, memo: r.memo, - amountCents: r.amount_cents + amountCents: r.amount_cents, }) ).length; } @@ -277,7 +304,7 @@ export function listRuleFires(db: DatabaseSync, ruleId: number): RuleFire[] { FROM categorization_events e JOIN transactions t ON t.id = e.transaction_id WHERE e.rule_id = ? - ORDER BY e.id DESC` + ORDER BY e.id DESC`, ) .all(ruleId) as Record[]; return rows.map((r) => ({ @@ -287,11 +314,15 @@ export function listRuleFires(db: DatabaseSync, ruleId: number): RuleFire[] { amountCents: r.amount_cents as number, effectiveAt: r.effective_at as number, firedAt: r.fired_at as string, - current: r.current === 1 + current: r.current === 1, })); } -export type ProbeStatus = 'uncategorized' | 'this-rule' | 'other-rule' | 'manual'; +export type ProbeStatus = + | "uncategorized" + | "this-rule" + | "other-rule" + | "manual"; export interface ProbeHit { transactionId: number; @@ -307,7 +338,11 @@ export interface ProbeHit { * of non-hidden accounts (wider than the uncategorized-only creation probe), * each hit labeled with its current categorization status. Mutates nothing. */ -export function probeRule(db: DatabaseSync, probe: RuleProbe, ruleId?: number): ProbeHit[] { +export function probeRule( + db: DatabaseSync, + probe: RuleProbe, + ruleId?: number, +): ProbeHit[] { const rule = probeAsRule(probe); const rows = db .prepare( @@ -320,7 +355,7 @@ export function probeRule(db: DatabaseSync, probe: RuleProbe, ruleId?: number): SELECT MAX(id) FROM categorization_events WHERE transaction_id = t.id ) WHERE t.removed_at IS NULL AND a.state != 'HIDDEN' - ORDER BY effective_at DESC` + ORDER BY effective_at DESC`, ) .all() as Record[]; @@ -331,23 +366,24 @@ export function probeRule(db: DatabaseSync, probe: RuleProbe, ruleId?: number): description: r.description as string, payee: r.payee as string | null, memo: r.memo as string | null, - amountCents: r.amount_cents as number + amountCents: r.amount_cents as number, }) ) { continue; } let status: ProbeStatus; - if (r.category_id == null) status = 'uncategorized'; - else if (r.latest_source === 'manual') status = 'manual'; - else if (ruleId != null && r.latest_rule_id === ruleId) status = 'this-rule'; - else status = 'other-rule'; + if (r.category_id == null) status = "uncategorized"; + else if (r.latest_source === "manual") status = "manual"; + else if (ruleId != null && r.latest_rule_id === ruleId) { + status = "this-rule"; + } else status = "other-rule"; hits.push({ transactionId: r.id as number, description: r.description as string, payee: r.payee as string | null, amountCents: r.amount_cents as number, effectiveAt: r.effective_at as number, - status + status, }); } return hits; @@ -365,19 +401,30 @@ export interface RuleMatchHealth { } /** Derived match health for a rule — nothing stored (design D4). */ -export function ruleMatchHealth(db: DatabaseSync, rule: Rule, missLimit = 10): RuleMatchHealth { +export function ruleMatchHealth( + db: DatabaseSync, + rule: Rule, + missLimit = 10, +): RuleMatchHealth { const fireRows = db .prepare( `SELECT MAX(created_at) AS last_fired, strftime('%Y-%m', created_at) AS month, COUNT(*) AS fires FROM categorization_events WHERE rule_id = ? - GROUP BY month ORDER BY month DESC` + GROUP BY month ORDER BY month DESC`, ) - .all(rule.id) as { last_fired: string | null; month: string; fires: number }[]; + .all(rule.id) as { + last_fired: string | null; + month: string; + fires: number; + }[]; let patternHitAmountMiss: ProbeHit[] = []; if (rule.amountCents != null) { - const textOnly = probeRule(db, { matchType: rule.matchType, pattern: rule.pattern }, rule.id); + const textOnly = probeRule(db, { + matchType: rule.matchType, + pattern: rule.pattern, + }, rule.id); patternHitAmountMiss = textOnly .filter((h) => h.amountCents !== rule.amountCents) .slice(0, missLimit); @@ -387,6 +434,6 @@ export function ruleMatchHealth(db: DatabaseSync, rule: Rule, missLimit = 10): R // Rows are ordered by month DESC, so the first row's max is the overall latest. lastFiredAt: fireRows[0]?.last_fired ?? null, firesByMonth: fireRows.map((r) => ({ month: r.month, fires: r.fires })), - patternHitAmountMiss + patternHitAmountMiss, }; } diff --git a/src/lib/server/services/sessions.ts b/src/lib/server/services/sessions.ts index 0009524..0fddce2 100644 --- a/src/lib/server/services/sessions.ts +++ b/src/lib/server/services/sessions.ts @@ -1,18 +1,21 @@ -import { randomBytes } from 'node:crypto'; -import type { DatabaseSync } from 'node:sqlite'; -import type { User } from './users.ts'; +import { randomBytes } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; +import type { User } from "./users.ts"; const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days // Sessions are opaque tokens (design D6): delivered via HTTP-only cookie today, // presentable as a bearer credential by future native clients. -export function createSession(db: DatabaseSync, did: string): { token: string; expiresAt: Date } { - const token = randomBytes(32).toString('base64url'); +export function createSession( + db: DatabaseSync, + did: string, +): { token: string; expiresAt: Date } { + const token = randomBytes(32).toString("base64url"); const now = new Date(); const expiresAt = new Date(now.getTime() + SESSION_TTL_MS); db.prepare( - 'INSERT INTO sessions (token, user_did, created_at, expires_at) VALUES (?, ?, ?, ?)' + "INSERT INTO sessions (token, user_did, created_at, expires_at) VALUES (?, ?, ?, ?)", ).run(token, did, now.toISOString(), expiresAt.toISOString()); return { token, expiresAt }; } @@ -21,9 +24,11 @@ export function getSessionUser(db: DatabaseSync, token: string): User | null { const row = db .prepare( `SELECT u.did, u.handle, s.expires_at FROM sessions s - JOIN users u ON u.did = s.user_did WHERE s.token = ?` + JOIN users u ON u.did = s.user_did WHERE s.token = ?`, ) - .get(token) as { did: string; handle: string; expires_at: string } | undefined; + .get(token) as + | { did: string; handle: string; expires_at: string } + | undefined; if (!row) return null; if (new Date(row.expires_at).getTime() <= Date.now()) { deleteSession(db, token); @@ -33,9 +38,11 @@ export function getSessionUser(db: DatabaseSync, token: string): User | null { } export function deleteSession(db: DatabaseSync, token: string): void { - db.prepare('DELETE FROM sessions WHERE token = ?').run(token); + db.prepare("DELETE FROM sessions WHERE token = ?").run(token); } export function deleteExpiredSessions(db: DatabaseSync): void { - db.prepare('DELETE FROM sessions WHERE expires_at <= ?').run(new Date().toISOString()); + db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run( + new Date().toISOString(), + ); } diff --git a/src/lib/server/services/sync-status.ts b/src/lib/server/services/sync-status.ts index 373478c..b30a98d 100644 --- a/src/lib/server/services/sync-status.ts +++ b/src/lib/server/services/sync-status.ts @@ -1,4 +1,4 @@ -import type { DatabaseSync } from 'node:sqlite'; +import type { DatabaseSync } from "node:sqlite"; export interface LastSync { fetchedAt: string; @@ -8,9 +8,15 @@ export interface LastSync { export function getLastSync(db: DatabaseSync): LastSync | null { const row = db - .prepare('SELECT fetched_at, ok, error FROM raw_syncs ORDER BY id DESC LIMIT 1') - .get() as { fetched_at: string; ok: number; error: string | null } | undefined; - return row ? { fetchedAt: row.fetched_at, ok: row.ok === 1, error: row.error } : null; + .prepare( + "SELECT fetched_at, ok, error FROM raw_syncs ORDER BY id DESC LIMIT 1", + ) + .get() as + | { fetched_at: string; ok: number; error: string | null } + | undefined; + return row + ? { fetchedAt: row.fetched_at, ok: row.ok === 1, error: row.error } + : null; } // Bridge advisories about our own request parameters (e.g. "Requested date @@ -22,12 +28,16 @@ const REQUEST_ADVISORY = /^requested date range/i; /** Connection-level errors reported by the most recent successful sync. */ export function getConnectionErrors(db: DatabaseSync): string[] { const row = db - .prepare('SELECT payload FROM raw_syncs WHERE ok = 1 ORDER BY id DESC LIMIT 1') + .prepare( + "SELECT payload FROM raw_syncs WHERE ok = 1 ORDER BY id DESC LIMIT 1", + ) .get() as { payload: string } | undefined; if (!row?.payload) return []; try { const parsed = JSON.parse(row.payload) as { errors?: unknown[] }; - return (parsed.errors ?? []).map((e) => String(e)).filter((e) => !REQUEST_ADVISORY.test(e)); + return (parsed.errors ?? []).map((e) => String(e)).filter((e) => + !REQUEST_ADVISORY.test(e) + ); } catch { return []; } diff --git a/src/lib/server/services/sync.test.ts b/src/lib/server/services/sync.test.ts index d770823..7761ecd 100644 --- a/src/lib/server/services/sync.test.ts +++ b/src/lib/server/services/sync.test.ts @@ -1,48 +1,56 @@ /// -import { openDatabase } from '../db.ts'; -import { runSync } from './sync.ts'; -import { categorizeManually } from './categorization.ts'; -import { commitImport, createDraftImport, saveMapping } from './imports.ts'; -import type { DatabaseSync } from 'node:sqlite'; +import { openDatabase } from "../db.ts"; +import { runSync } from "./sync.ts"; +import { categorizeManually } from "./categorization.ts"; +import { commitImport, createDraftImport, saveMapping } from "./imports.ts"; +import type { DatabaseSync } from "node:sqlite"; -const MIGRATIONS_DIR = new URL('../../../../migrations', import.meta.url).pathname.replace( - /^\/([A-Za-z]:)/, - '$1' -); +const MIGRATIONS_DIR = new URL("../../../../migrations", import.meta.url) + .pathname.replace( + /^\/([A-Za-z]:)/, + "$1", + ); function testDb(): DatabaseSync { const db = openDatabase(`${Deno.makeTempDirSync()}/test.db`, MIGRATIONS_DIR); db.prepare( - "INSERT INTO connections (access_url, claimed_at) VALUES ('https://u:p@bridge.test/simplefin', ?)" + "INSERT INTO connections (access_url, claimed_at) VALUES ('https://u:p@bridge.test/simplefin', ?)", ).run(new Date().toISOString()); - db.prepare("INSERT INTO users (did, handle, created_at) VALUES ('did:plc:t', 'tester', ?)").run( - new Date().toISOString() + db.prepare( + "INSERT INTO users (did, handle, created_at) VALUES ('did:plc:t', 'tester', ?)", + ).run( + new Date().toISOString(), ); return db; } function fakeFetch(payload: unknown): typeof fetch { return (() => - Promise.resolve(new Response(JSON.stringify(payload), { status: 200 }))) as typeof fetch; + Promise.resolve( + new Response(JSON.stringify(payload), { status: 200 }), + )) as typeof fetch; } const NOW_S = Math.floor(Date.now() / 1000); -function payloadWith(transactions: unknown[], accountOverrides: Record = {}) { +function payloadWith( + transactions: unknown[], + accountOverrides: Record = {}, +) { return { errors: [], accounts: [ { - org: { name: 'Test Bank', domain: 'bank.test' }, - id: 'act-1', - name: 'Checking', - currency: 'USD', - balance: '100.00', - 'balance-date': NOW_S, + org: { name: "Test Bank", domain: "bank.test" }, + id: "act-1", + name: "Checking", + currency: "USD", + balance: "100.00", + "balance-date": NOW_S, transactions, - ...accountOverrides - } - ] + ...accountOverrides, + }, + ], }; } @@ -50,237 +58,346 @@ function count(db: DatabaseSync, sql: string): number { return (db.prepare(sql).get() as { n: number }).n; } -Deno.test('a sync after a backfill leaves the imported rows alone', async () => { +Deno.test("a sync after a backfill leaves the imported rows alone", async () => { // The hazard this guards: ingestTransactions treats its feed as authoritative // for the account and soft-removes anything pending that the feed omits. A // backfill CSV appears in no feed, ever. Imported rows are posted (pending = 0), // which is what keeps them outside every branch of sync's authority — a // structural property worth pinning down rather than rediscovering. const db = testDb(); - const pendingTxn = { id: 'p1', posted: null, amount: '-5.00', description: 'PENDING COFFEE' }; + const pendingTxn = { + id: "p1", + posted: null, + amount: "-5.00", + description: "PENDING COFFEE", + }; await runSync(db, fakeFetch(payloadWith([pendingTxn]))); // Backfill straight into the discovered account, well before the feed's reach. - const importId = createDraftImport(db, 'act-1', 'old.csv', 'Date,Description,Amount\n2023-02-01,ANCIENT LUNCH,-9.99\n'); + const importId = createDraftImport( + db, + "act-1", + "old.csv", + "Date,Description,Amount\n2023-02-01,ANCIENT LUNCH,-9.99\n", + ); saveMapping(db, importId, { - date: 'Date', - dateFormat: 'iso', - amountMode: 'signed', - amount: 'Amount', - description: 'Description' + date: "Date", + dateFormat: "iso", + amountMode: "signed", + amount: "Amount", + description: "Description", }); commitImport(db, importId); // A later sync of the same connection: the feed still knows nothing of 2023. - await runSync(db, fakeFetch(payloadWith([pendingTxn, { id: 't9', posted: NOW_S, amount: '-1.00', description: 'NEW THING' }]))); + await runSync( + db, + fakeFetch( + payloadWith([pendingTxn, { + id: "t9", + posted: NOW_S, + amount: "-1.00", + description: "NEW THING", + }]), + ), + ); const imported = db - .prepare("SELECT removed_at, pending FROM transactions WHERE description = 'ANCIENT LUNCH'") + .prepare( + "SELECT removed_at, pending FROM transactions WHERE description = 'ANCIENT LUNCH'", + ) .get() as { removed_at: string | null; pending: number }; - if (imported.removed_at !== null) throw new Error('a sync must not remove backfilled rows'); - if (imported.pending !== 0) throw new Error('imported rows must stay posted'); + if (imported.removed_at !== null) { + throw new Error("a sync must not remove backfilled rows"); + } + if (imported.pending !== 0) throw new Error("imported rows must stay posted"); - if (count(db, "SELECT COUNT(*) n FROM transactions WHERE description = 'ANCIENT LUNCH'") !== 1) { - throw new Error('a sync must not duplicate a backfilled row'); + if ( + count( + db, + "SELECT COUNT(*) n FROM transactions WHERE description = 'ANCIENT LUNCH'", + ) !== 1 + ) { + throw new Error("a sync must not duplicate a backfilled row"); } // And the account is still healthy: the backfill did not make it look absent. - const account = db.prepare("SELECT state FROM accounts WHERE id = 'act-1'").get() as { - state: string; - }; - if (account.state === 'INACTIVE') throw new Error('the account should not have gone inactive'); + const account = db.prepare("SELECT state FROM accounts WHERE id = 'act-1'") + .get() as { + state: string; + }; + if (account.state === "INACTIVE") { + throw new Error("the account should not have gone inactive"); + } db.close(); }); -Deno.test('sync archives raw payload, discovers account as NEW, snapshots balance', async () => { +Deno.test("sync archives raw payload, discovers account as NEW, snapshots balance", async () => { const db = testDb(); const payload = payloadWith([ - { id: 't1', posted: NOW_S - 1000, amount: '-10.00', description: 'STORE A' } + { + id: "t1", + posted: NOW_S - 1000, + amount: "-10.00", + description: "STORE A", + }, ]); const [outcome] = await runSync(db, fakeFetch(payload)); if (!outcome.ok) throw new Error(`sync failed: ${outcome.error}`); - if (outcome.newTransactions !== 1) throw new Error('expected 1 new transaction'); + if (outcome.newTransactions !== 1) { + throw new Error("expected 1 new transaction"); + } // first sync = routine pass + deep backfill pass (new account discovered) - if (count(db, 'SELECT COUNT(*) n FROM raw_syncs WHERE ok = 1') !== 2) - throw new Error('expected both passes archived'); - const account = db.prepare("SELECT state FROM accounts WHERE id = 'act-1'").get() as { - state: string; - }; - if (account.state !== 'NEW') throw new Error(`expected NEW, got ${account.state}`); - if (count(db, 'SELECT COUNT(*) n FROM balance_snapshots') !== 2) - throw new Error('expected a snapshot per pass'); + if (count(db, "SELECT COUNT(*) n FROM raw_syncs WHERE ok = 1") !== 2) { + throw new Error("expected both passes archived"); + } + const account = db.prepare("SELECT state FROM accounts WHERE id = 'act-1'") + .get() as { + state: string; + }; + if (account.state !== "NEW") { + throw new Error(`expected NEW, got ${account.state}`); + } + if (count(db, "SELECT COUNT(*) n FROM balance_snapshots") !== 2) { + throw new Error("expected a snapshot per pass"); + } db.close(); }); -Deno.test('new accounts trigger one deep backfill pass; established syncs fetch once', async () => { +Deno.test("new accounts trigger one deep backfill pass; established syncs fetch once", async () => { const db = testDb(); const startDates: number[] = []; const countingFetch = ((input: RequestInfo | URL) => { - startDates.push(Number(new URL(String(input)).searchParams.get('start-date'))); + startDates.push( + Number(new URL(String(input)).searchParams.get("start-date")), + ); return Promise.resolve( - new Response(JSON.stringify(payloadWith([])), { status: 200 }) + new Response(JSON.stringify(payloadWith([])), { status: 200 }), ); }) as typeof fetch; await runSync(db, countingFetch); // discovers act-1 - if (startDates.length !== 2) throw new Error(`expected 2 fetches on first sync, got ${startDates.length}`); + if (startDates.length !== 2) { + throw new Error( + `expected 2 fetches on first sync, got ${startDates.length}`, + ); + } const daysBack = (ts: number) => Math.round((Date.now() / 1000 - ts) / 86400); - if (daysBack(startDates[0]) > 45) throw new Error('routine pass should stay under 45 days'); - if (daysBack(startDates[1]) < 60) throw new Error('backfill pass should reach deep'); + if (daysBack(startDates[0]) > 45) { + throw new Error("routine pass should stay under 45 days"); + } + if (daysBack(startDates[1]) < 60) { + throw new Error("backfill pass should reach deep"); + } await runSync(db, countingFetch); // no new accounts now const totalFetches: number = startDates.length; - if (totalFetches !== 3) throw new Error('established sync should fetch exactly once'); + if (totalFetches !== 3) { + throw new Error("established sync should fetch exactly once"); + } db.close(); }); -Deno.test('re-syncing the same payload is a no-op for transactions', async () => { +Deno.test("re-syncing the same payload is a no-op for transactions", async () => { const db = testDb(); const payload = payloadWith([ - { id: 't1', posted: NOW_S - 1000, amount: '-10.00', description: 'STORE A' }, - { id: 't2', posted: NOW_S - 2000, amount: '2500.00', description: 'PAYROLL' } + { + id: "t1", + posted: NOW_S - 1000, + amount: "-10.00", + description: "STORE A", + }, + { + id: "t2", + posted: NOW_S - 2000, + amount: "2500.00", + description: "PAYROLL", + }, ]); await runSync(db, fakeFetch(payload)); const [second] = await runSync(db, fakeFetch(payload)); - if (second.newTransactions !== 0) throw new Error('second sync inserted duplicates'); - if (count(db, 'SELECT COUNT(*) n FROM transactions') !== 2) - throw new Error('expected exactly 2 transactions'); + if (second.newTransactions !== 0) { + throw new Error("second sync inserted duplicates"); + } + if (count(db, "SELECT COUNT(*) n FROM transactions") !== 2) { + throw new Error("expected exactly 2 transactions"); + } // snapshots accumulate per pass: first sync (2 passes) + second sync (1) - if (count(db, 'SELECT COUNT(*) n FROM balance_snapshots') !== 3) - throw new Error('expected 3 snapshots'); + if (count(db, "SELECT COUNT(*) n FROM balance_snapshots") !== 3) { + throw new Error("expected 3 snapshots"); + } db.close(); }); -Deno.test('range advisories are excluded from connection-error banners', async () => { +Deno.test("range advisories are excluded from connection-error banners", async () => { const db = testDb(); const payload = { errors: [ - 'Requested date range exceeds recommended range of 45 days. In the future, this may be capped.', - 'Connection to Chase needs attention' + "Requested date range exceeds recommended range of 45 days. In the future, this may be capped.", + "Connection to Chase needs attention", ], - accounts: [] + accounts: [], }; await runSync(db, fakeFetch(payload)); - const { getConnectionErrors } = await import('./sync-status.ts'); + const { getConnectionErrors } = await import("./sync-status.ts"); const errors = getConnectionErrors(db); - if (errors.length !== 1 || !errors[0].includes('Chase')) { - throw new Error(`advisory should be filtered, got: ${JSON.stringify(errors)}`); + if (errors.length !== 1 || !errors[0].includes("Chase")) { + throw new Error( + `advisory should be filtered, got: ${JSON.stringify(errors)}`, + ); } db.close(); }); -Deno.test('categorized pending transaction posting under a new id carries category via reconciliation event', async () => { +Deno.test("categorized pending transaction posting under a new id carries category via reconciliation event", async () => { const db = testDb(); await runSync( db, fakeFetch( payloadWith([ { - id: 'pend-1', + id: "pend-1", posted: 0, pending: true, transacted_at: NOW_S - 3600, - amount: '-25.00', - description: 'COFFEE SHOP (PENDING)' - } - ]) - ) + amount: "-25.00", + description: "COFFEE SHOP (PENDING)", + }, + ]), + ), ); - const pendingRow = db.prepare('SELECT id FROM transactions').get() as { id: number }; - db.prepare("INSERT INTO categories (name, kind, created_at) VALUES ('Coffee','expense',?)").run( - new Date().toISOString() + const pendingRow = db.prepare("SELECT id FROM transactions").get() as { + id: number; + }; + db.prepare( + "INSERT INTO categories (name, kind, created_at) VALUES ('Coffee','expense',?)", + ).run( + new Date().toISOString(), ); const categoryId = Number( - (db.prepare("SELECT id FROM categories WHERE name='Coffee'").get() as { id: number }).id + (db.prepare("SELECT id FROM categories WHERE name='Coffee'").get() as { + id: number; + }).id, ); - categorizeManually(db, pendingRow.id, categoryId, 'did:plc:t'); + categorizeManually(db, pendingRow.id, categoryId, "did:plc:t"); // Next sync: pending vanished, posted appears under a different id. const [outcome] = await runSync( db, fakeFetch( payloadWith([ - { id: 'post-9', posted: NOW_S, amount: '-25.00', description: 'COFFEE SHOP' } - ]) - ) + { + id: "post-9", + posted: NOW_S, + amount: "-25.00", + description: "COFFEE SHOP", + }, + ]), + ), ); - if (outcome.reconciled !== 1) throw new Error(`expected 1 reconciled, got ${outcome.reconciled}`); + if (outcome.reconciled !== 1) { + throw new Error(`expected 1 reconciled, got ${outcome.reconciled}`); + } const txn = db - .prepare("SELECT id, sfin_id, pending, category_id, removed_at FROM transactions") + .prepare( + "SELECT id, sfin_id, pending, category_id, removed_at FROM transactions", + ) .get() as Record; - if (txn.sfin_id !== 'post-9') throw new Error('row not replaced in place'); - if (txn.pending !== 0) throw new Error('still pending'); - if (txn.category_id !== categoryId) throw new Error('category not carried'); - if (txn.removed_at !== null) throw new Error('should not be removed'); + if (txn.sfin_id !== "post-9") throw new Error("row not replaced in place"); + if (txn.pending !== 0) throw new Error("still pending"); + if (txn.category_id !== categoryId) throw new Error("category not carried"); + if (txn.removed_at !== null) throw new Error("should not be removed"); const events = db - .prepare('SELECT source FROM categorization_events ORDER BY id') + .prepare("SELECT source FROM categorization_events ORDER BY id") .all() as { source: string }[]; - if (events.map((e) => e.source).join(',') !== 'manual,reconciliation') + if (events.map((e) => e.source).join(",") !== "manual,reconciliation") { throw new Error(`unexpected event trail: ${events.map((e) => e.source)}`); + } db.close(); }); -Deno.test('stale pending transaction with no posted match is soft-removed', async () => { +Deno.test("stale pending transaction with no posted match is soft-removed", async () => { const db = testDb(); await runSync( db, fakeFetch( payloadWith([ - { id: 'pend-2', posted: 0, pending: true, amount: '-5.00', description: 'GHOST' } - ]) - ) + { + id: "pend-2", + posted: 0, + pending: true, + amount: "-5.00", + description: "GHOST", + }, + ]), + ), ); const [outcome] = await runSync(db, fakeFetch(payloadWith([]))); - if (outcome.removedPending !== 1) throw new Error('expected 1 removed pending'); - const row = db.prepare('SELECT removed_at FROM transactions').get() as { + if (outcome.removedPending !== 1) { + throw new Error("expected 1 removed pending"); + } + const row = db.prepare("SELECT removed_at FROM transactions").get() as { removed_at: string | null; }; - if (!row.removed_at) throw new Error('pending row not soft-removed'); + if (!row.removed_at) throw new Error("pending row not soft-removed"); db.close(); }); -Deno.test('ACTIVE account absent from feed goes INACTIVE and returns on reappearance', async () => { +Deno.test("ACTIVE account absent from feed goes INACTIVE and returns on reappearance", async () => { const db = testDb(); await runSync(db, fakeFetch(payloadWith([]))); - db.prepare("UPDATE accounts SET state='ACTIVE', account_type='checking' WHERE id='act-1'").run(); + db.prepare( + "UPDATE accounts SET state='ACTIVE', account_type='checking' WHERE id='act-1'", + ).run(); // feed with a different account only const otherAccount = { errors: [], accounts: [ { - org: { name: 'Other' }, - id: 'act-2', - name: 'Savings', - currency: 'USD', - balance: '1.00', - transactions: [] - } - ] + org: { name: "Other" }, + id: "act-2", + name: "Savings", + currency: "USD", + balance: "1.00", + transactions: [], + }, + ], }; await runSync(db, fakeFetch(otherAccount)); - let state = (db.prepare("SELECT state FROM accounts WHERE id='act-1'").get() as { state: string }) - .state; - if (state !== 'INACTIVE') throw new Error(`expected INACTIVE, got ${state}`); + let state = + (db.prepare("SELECT state FROM accounts WHERE id='act-1'").get() as { + state: string; + }) + .state; + if (state !== "INACTIVE") throw new Error(`expected INACTIVE, got ${state}`); await runSync(db, fakeFetch(payloadWith([]))); - state = (db.prepare("SELECT state FROM accounts WHERE id='act-1'").get() as { state: string }) + state = (db.prepare("SELECT state FROM accounts WHERE id='act-1'").get() as { + state: string; + }) .state; - if (state !== 'ACTIVE') throw new Error(`expected ACTIVE after reappearing, got ${state}`); + if (state !== "ACTIVE") { + throw new Error(`expected ACTIVE after reappearing, got ${state}`); + } db.close(); }); -Deno.test('failed fetch archives a failure row and touches nothing else', async () => { +Deno.test("failed fetch archives a failure row and touches nothing else", async () => { const db = testDb(); - const failingFetch = (() => Promise.reject(new Error('network down'))) as unknown as typeof fetch; + const failingFetch = + (() => + Promise.reject(new Error("network down"))) as unknown as typeof fetch; const [outcome] = await runSync(db, failingFetch); - if (outcome.ok) throw new Error('expected failure'); - const raw = db.prepare('SELECT ok, error FROM raw_syncs').get() as { + if (outcome.ok) throw new Error("expected failure"); + const raw = db.prepare("SELECT ok, error FROM raw_syncs").get() as { ok: number; error: string; }; - if (raw.ok !== 0 || !raw.error.includes('network down')) - throw new Error('failure not recorded'); - if (count(db, 'SELECT COUNT(*) n FROM transactions') !== 0) - throw new Error('transactions should be untouched'); + if (raw.ok !== 0 || !raw.error.includes("network down")) { + throw new Error("failure not recorded"); + } + if (count(db, "SELECT COUNT(*) n FROM transactions") !== 0) { + throw new Error("transactions should be untouched"); + } db.close(); }); diff --git a/src/lib/server/services/sync.ts b/src/lib/server/services/sync.ts index 85be8c8..e5da8cd 100644 --- a/src/lib/server/services/sync.ts +++ b/src/lib/server/services/sync.ts @@ -1,9 +1,9 @@ -import type { DatabaseSync } from 'node:sqlite'; -import { fetchAccounts } from '../simplefin.ts'; -import { normalizePayload, type NormalizedAccount } from './normalize.ts'; -import { listConnections } from './connections.ts'; -import { appendCategorizationEvent } from './categorization.ts'; -import { applyRulesToUncategorized } from './rules.ts'; +import type { DatabaseSync } from "node:sqlite"; +import { fetchAccounts } from "../simplefin.ts"; +import { type NormalizedAccount, normalizePayload } from "./normalize.ts"; +import { listConnections } from "./connections.ts"; +import { appendCategorizationEvent } from "./categorization.ts"; +import { applyRulesToUncategorized } from "./rules.ts"; // The Bridge recommends ranges <= 45 days (observed: larger requests add a // "Requested date range exceeds recommended range of 45 days" advisory to the @@ -29,11 +29,13 @@ export interface SyncOutcome { /** Sync every connection: archive verbatim, normalize, snapshot, reconcile, rule-fire. */ export async function runSync( db: DatabaseSync, - fetchFn: typeof fetch = fetch + fetchFn: typeof fetch = fetch, ): Promise { const outcomes: SyncOutcome[] = []; for (const connection of listConnections(db)) { - outcomes.push(await syncConnection(db, connection.id, connection.accessUrl, fetchFn)); + outcomes.push( + await syncConnection(db, connection.id, connection.accessUrl, fetchFn), + ); } return outcomes; } @@ -42,7 +44,7 @@ async function syncConnection( db: DatabaseSync, connectionId: number, accessUrl: string, - fetchFn: typeof fetch + fetchFn: typeof fetch, ): Promise { const outcome: SyncOutcome = { connectionId, @@ -51,15 +53,29 @@ async function syncConnection( reconciled: 0, removedPending: 0, ruleCategorized: 0, - connectionErrors: [] + connectionErrors: [], }; - const routine = await syncPass(db, connectionId, accessUrl, ROUTINE_LOOKBACK_DAYS, outcome, fetchFn); + const routine = await syncPass( + db, + connectionId, + accessUrl, + ROUTINE_LOOKBACK_DAYS, + outcome, + fetchFn, + ); // Never-before-seen accounts (including all of them, on a connection's very // first sync) get one deep fetch for their full available backfill. if (routine.newAccountIds.length > 0) { - await syncPass(db, connectionId, accessUrl, BACKFILL_LOOKBACK_DAYS, outcome, fetchFn); + await syncPass( + db, + connectionId, + accessUrl, + BACKFILL_LOOKBACK_DAYS, + outcome, + fetchFn, + ); } if (outcome.ok) outcome.ruleCategorized = applyRulesToUncategorized(db); @@ -73,16 +89,26 @@ async function syncPass( accessUrl: string, lookbackDays: number, outcome: SyncOutcome, - fetchFn: typeof fetch + fetchFn: typeof fetch, ): Promise<{ newAccountIds: string[] }> { const startDate = new Date(Date.now() - lookbackDays * 86400_000); - const fetched = await fetchAccounts(accessUrl, { startDate, pending: true }, fetchFn); + const fetched = await fetchAccounts( + accessUrl, + { startDate, pending: true }, + fetchFn, + ); const fetchedAt = new Date().toISOString(); // Archive verbatim before any processing — success or failure. db.prepare( - 'INSERT INTO raw_syncs (connection_id, fetched_at, ok, payload, error) VALUES (?, ?, ?, ?, ?)' - ).run(connectionId, fetchedAt, fetched.ok ? 1 : 0, fetched.body ?? null, fetched.error ?? null); + "INSERT INTO raw_syncs (connection_id, fetched_at, ok, payload, error) VALUES (?, ?, ?, ?, ?)", + ).run( + connectionId, + fetchedAt, + fetched.ok ? 1 : 0, + fetched.body ?? null, + fetched.error ?? null, + ); if (!fetched.ok || !fetched.body) { outcome.ok = false; @@ -95,11 +121,15 @@ async function syncPass( normalized = normalizePayload(fetched.body); } catch (err) { outcome.ok = false; - outcome.error = `Normalization failed: ${err instanceof Error ? err.message : err}`; - db.prepare('UPDATE raw_syncs SET ok = 0, error = ? WHERE connection_id = ? AND fetched_at = ?').run( + outcome.error = `Normalization failed: ${ + err instanceof Error ? err.message : err + }`; + db.prepare( + "UPDATE raw_syncs SET ok = 0, error = ? WHERE connection_id = ? AND fetched_at = ?", + ).run( outcome.error, connectionId, - fetchedAt + fetchedAt, ); return { newAccountIds: [] }; } @@ -107,23 +137,30 @@ async function syncPass( const newAccountIds: string[] = []; for (const account of normalized.accounts) { - if (upsertAccount(db, connectionId, account, fetchedAt)) newAccountIds.push(account.id); + if (upsertAccount(db, connectionId, account, fetchedAt)) { + newAccountIds.push(account.id); + } const result = ingestTransactions(db, account); outcome.newTransactions += result.inserted; outcome.reconciled += result.reconciled; outcome.removedPending += result.removedPending; db.prepare( - 'INSERT INTO balance_snapshots (account_id, captured_at, balance_cents, available_balance_cents) VALUES (?, ?, ?, ?)' - ).run(account.id, fetchedAt, account.balanceCents, account.availableBalanceCents); + "INSERT INTO balance_snapshots (account_id, captured_at, balance_cents, available_balance_cents) VALUES (?, ?, ?, ?)", + ).run( + account.id, + fetchedAt, + account.balanceCents, + account.availableBalanceCents, + ); } // Lifecycle: ACTIVE accounts of this connection absent from the feed go INACTIVE. const seenIds = normalized.accounts.map((a) => a.id); - const placeholders = seenIds.map(() => '?').join(','); + const placeholders = seenIds.map(() => "?").join(","); db.prepare( `UPDATE accounts SET state = 'INACTIVE' WHERE connection_id = ? AND state = 'ACTIVE' - ${seenIds.length ? `AND id NOT IN (${placeholders})` : ''}` + ${seenIds.length ? `AND id NOT IN (${placeholders})` : ""}`, ).run(connectionId, ...seenIds); return { newAccountIds }; @@ -134,16 +171,17 @@ function upsertAccount( db: DatabaseSync, connectionId: number, account: NormalizedAccount, - fetchedAt: string + fetchedAt: string, ): boolean { - const existing = db.prepare('SELECT id, state FROM accounts WHERE id = ?').get(account.id) as - | { id: string; state: string } - | undefined; + const existing = db.prepare("SELECT id, state FROM accounts WHERE id = ?") + .get(account.id) as + | { id: string; state: string } + | undefined; if (!existing) { db.prepare( `INSERT INTO accounts (id, connection_id, org_name, org_domain, org_sfin_url, name, currency, state, last_successful_data_at, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 'NEW', ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'NEW', ?, ?)`, ).run( account.id, connectionId, @@ -153,7 +191,7 @@ function upsertAccount( account.name, account.currency, fetchedAt, - fetchedAt + fetchedAt, ); return true; } @@ -167,7 +205,7 @@ function upsertAccount( WHEN state = 'INACTIVE' THEN 'NEW' ELSE state END - WHERE id = ?` + WHERE id = ?`, ).run( account.org.name, account.org.domain, @@ -175,14 +213,14 @@ function upsertAccount( account.name, account.currency, fetchedAt, - account.id + account.id, ); return false; } function ingestTransactions( db: DatabaseSync, - account: NormalizedAccount + account: NormalizedAccount, ): { inserted: number; reconciled: number; removedPending: number } { const now = new Date().toISOString(); let inserted = 0; @@ -192,8 +230,12 @@ function ingestTransactions( for (const txn of account.transactions) { const existing = db - .prepare('SELECT id, pending FROM transactions WHERE account_id = ? AND sfin_id = ?') - .get(account.id, txn.sfinId) as { id: number; pending: number } | undefined; + .prepare( + "SELECT id, pending FROM transactions WHERE account_id = ? AND sfin_id = ?", + ) + .get(account.id, txn.sfinId) as + | { id: number; pending: number } + | undefined; if (existing) { // Idempotent refresh; a known-pending transaction that posts under the @@ -201,7 +243,7 @@ function ingestTransactions( db.prepare( `UPDATE transactions SET posted = ?, transacted_at = ?, amount_cents = ?, description = ?, payee = ?, memo = ?, pending = ?, extra = ?, removed_at = NULL - WHERE id = ?` + WHERE id = ?`, ).run( txn.posted, txn.transactedAt, @@ -211,7 +253,7 @@ function ingestTransactions( txn.memo, txn.pending ? 1 : 0, txn.extra, - existing.id + existing.id, ); continue; } @@ -226,22 +268,28 @@ function ingestTransactions( `SELECT id, sfin_id FROM transactions WHERE account_id = ? AND pending = 1 AND removed_at IS NULL AND amount_cents = ? AND COALESCE(transacted_at, posted, ?) BETWEEN ? AND ? - ORDER BY id LIMIT 1` + ORDER BY id LIMIT 1`, ) - .get(account.id, txn.amountCents, txn.posted ?? 0, windowStart, windowEnd) as - | { id: number; sfin_id: string } - | undefined; + .get( + account.id, + txn.amountCents, + txn.posted ?? 0, + windowStart, + windowEnd, + ) as + | { id: number; sfin_id: string } + | undefined; if (match && !feedSfinIds.has(match.sfin_id)) { // Replace in place: same row id keeps the event history attached; // a reconciliation event records the carry-forward. const category = db - .prepare('SELECT category_id FROM transactions WHERE id = ?') + .prepare("SELECT category_id FROM transactions WHERE id = ?") .get(match.id) as { category_id: number | null }; db.prepare( `UPDATE transactions SET sfin_id = ?, posted = ?, transacted_at = ?, amount_cents = ?, description = ?, payee = ?, memo = ?, pending = 0, extra = ?, removed_at = NULL - WHERE id = ?` + WHERE id = ?`, ).run( txn.sfinId, txn.posted, @@ -251,13 +299,13 @@ function ingestTransactions( txn.payee, txn.memo, txn.extra, - match.id + match.id, ); if (category.category_id != null) { appendCategorizationEvent(db, { transactionId: match.id, categoryId: category.category_id, - source: 'reconciliation' + source: "reconciliation", }); } reconciled++; @@ -268,7 +316,7 @@ function ingestTransactions( db.prepare( `INSERT INTO transactions (account_id, sfin_id, posted, transacted_at, amount_cents, description, payee, memo, pending, extra, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( account.id, txn.sfinId, @@ -280,7 +328,7 @@ function ingestTransactions( txn.memo, txn.pending ? 1 : 0, txn.extra, - now + now, ); inserted++; } @@ -290,13 +338,16 @@ function ingestTransactions( // never deleted). const stale = db .prepare( - 'SELECT id, sfin_id FROM transactions WHERE account_id = ? AND pending = 1 AND removed_at IS NULL' + "SELECT id, sfin_id FROM transactions WHERE account_id = ? AND pending = 1 AND removed_at IS NULL", ) .all(account.id) as { id: number; sfin_id: string }[]; let removedPending = 0; for (const row of stale) { if (feedSfinIds.has(row.sfin_id)) continue; - db.prepare('UPDATE transactions SET removed_at = ? WHERE id = ?').run(now, row.id); + db.prepare("UPDATE transactions SET removed_at = ? WHERE id = ?").run( + now, + row.id, + ); removedPending++; } diff --git a/src/lib/server/services/users.ts b/src/lib/server/services/users.ts index 07f97f2..bbdc149 100644 --- a/src/lib/server/services/users.ts +++ b/src/lib/server/services/users.ts @@ -1,4 +1,4 @@ -import type { DatabaseSync } from 'node:sqlite'; +import type { DatabaseSync } from "node:sqlite"; export interface User { did: string; @@ -6,17 +6,23 @@ export interface User { } /** Create or refresh the user record for an allowlisted DID at login time. */ -export function upsertUser(db: DatabaseSync, did: string, handle: string): User { +export function upsertUser( + db: DatabaseSync, + did: string, + handle: string, +): User { const now = new Date().toISOString(); db.prepare( `INSERT INTO users (did, handle, created_at, last_login_at) VALUES (?, ?, ?, ?) - ON CONFLICT (did) DO UPDATE SET handle = excluded.handle, last_login_at = excluded.last_login_at` + ON CONFLICT (did) DO UPDATE SET handle = excluded.handle, last_login_at = excluded.last_login_at`, ).run(did, handle, now, now); return { did, handle }; } export function getUser(db: DatabaseSync, did: string): User | null { - const row = db.prepare('SELECT did, handle FROM users WHERE did = ?').get(did) as + const row = db.prepare("SELECT did, handle FROM users WHERE did = ?").get( + did, + ) as | User | undefined; return row ?? null; diff --git a/src/lib/server/simplefin.ts b/src/lib/server/simplefin.ts index 8068d91..d4ec6d1 100644 --- a/src/lib/server/simplefin.ts +++ b/src/lib/server/simplefin.ts @@ -5,7 +5,7 @@ export class ClaimError extends Error { constructor( message: string, - public readonly alreadyClaimed: boolean + public readonly alreadyClaimed: boolean, ) { super(message); } @@ -17,10 +17,16 @@ export function decodeSetupToken(token: string): string { try { claimUrl = atob(token.trim()); } catch { - throw new ClaimError('That does not look like a SimpleFIN setup token.', false); + throw new ClaimError( + "That does not look like a SimpleFIN setup token.", + false, + ); } - if (!claimUrl.startsWith('https://')) { - throw new ClaimError('Setup token did not decode to an https claim URL.', false); + if (!claimUrl.startsWith("https://")) { + throw new ClaimError( + "Setup token did not decode to an https claim URL.", + false, + ); } return claimUrl; } @@ -28,31 +34,37 @@ export function decodeSetupToken(token: string): string { /** Claim a setup token, returning the permanent Access URL. */ export async function claimAccessUrl( claimUrl: string, - fetchFn: typeof fetch = fetch + fetchFn: typeof fetch = fetch, ): Promise { - const res = await fetchFn(claimUrl, { method: 'POST' }); + const res = await fetchFn(claimUrl, { method: "POST" }); if (res.status === 403) { throw new ClaimError( - 'This token was already claimed. Generate a fresh one at SimpleFIN Bridge.', - true + "This token was already claimed. Generate a fresh one at SimpleFIN Bridge.", + true, ); } if (!res.ok) { throw new ClaimError(`Claim failed (HTTP ${res.status}).`, false); } const accessUrl = (await res.text()).trim(); - if (!accessUrl.startsWith('https://')) { - throw new ClaimError('Claim response was not an Access URL.', false); + if (!accessUrl.startsWith("https://")) { + throw new ClaimError("Claim response was not an Access URL.", false); } return accessUrl; } -export function parseAccessUrl(accessUrl: string): { baseUrl: string; authHeader: string } { +export function parseAccessUrl( + accessUrl: string, +): { baseUrl: string; authHeader: string } { const url = new URL(accessUrl); - const authHeader = `Basic ${btoa(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`)}`; - url.username = ''; - url.password = ''; - return { baseUrl: url.toString().replace(/\/$/, ''), authHeader }; + const authHeader = `Basic ${ + btoa( + `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`, + ) + }`; + url.username = ""; + url.password = ""; + return { baseUrl: url.toString().replace(/\/$/, ""), authHeader }; } export interface FetchAccountsResult { @@ -66,21 +78,32 @@ export interface FetchAccountsResult { export async function fetchAccounts( accessUrl: string, options: { startDate: Date; pending?: boolean }, - fetchFn: typeof fetch = fetch + fetchFn: typeof fetch = fetch, ): Promise { const { baseUrl, authHeader } = parseAccessUrl(accessUrl); const url = new URL(`${baseUrl}/accounts`); - url.searchParams.set('start-date', String(Math.floor(options.startDate.getTime() / 1000))); - if (options.pending !== false) url.searchParams.set('pending', '1'); + url.searchParams.set( + "start-date", + String(Math.floor(options.startDate.getTime() / 1000)), + ); + if (options.pending !== false) url.searchParams.set("pending", "1"); try { const res = await fetchFn(url, { headers: { Authorization: authHeader } }); const body = await res.text(); if (!res.ok) { - return { ok: false, status: res.status, body, error: `HTTP ${res.status}` }; + return { + ok: false, + status: res.status, + body, + error: `HTTP ${res.status}`, + }; } return { ok: true, status: res.status, body }; } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; } } diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts index 45f4fcc..d4c276a 100644 --- a/src/routes/(app)/+layout.server.ts +++ b/src/routes/(app)/+layout.server.ts @@ -1,4 +1,4 @@ -import type { LayoutServerLoad } from './$types'; +import type { LayoutServerLoad } from "./$types"; export const load: LayoutServerLoad = ({ locals }) => { return { user: locals.user }; diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index ce1d359..7737624 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -1,38 +1,40 @@
@@ -63,137 +65,137 @@
diff --git a/src/routes/(app)/+page.server.ts b/src/routes/(app)/+page.server.ts index 8cf201e..d991d8a 100644 --- a/src/routes/(app)/+page.server.ts +++ b/src/routes/(app)/+page.server.ts @@ -1,10 +1,13 @@ -import { getDb } from '$lib/server/db'; -import { listAccounts, listStaleAccounts } from '$lib/server/services/accounts'; -import { listConnections } from '$lib/server/services/connections'; -import { listLedger } from '$lib/server/services/ledger'; -import { monthlyReport, pendingStats } from '$lib/server/services/reports'; -import { getConnectionErrors, getLastSync } from '$lib/server/services/sync-status'; -import type { PageServerLoad } from './$types'; +import { getDb } from "$lib/server/db"; +import { listAccounts, listStaleAccounts } from "$lib/server/services/accounts"; +import { listConnections } from "$lib/server/services/connections"; +import { listLedger } from "$lib/server/services/ledger"; +import { monthlyReport, pendingStats } from "$lib/server/services/reports"; +import { + getConnectionErrors, + getLastSync, +} from "$lib/server/services/sync-status"; +import type { PageServerLoad } from "./$types"; const RECENT_LIMIT = 8; @@ -15,14 +18,14 @@ export const load: PageServerLoad = () => { const month = new Date().toISOString().slice(0, 7); return { hasConnection: listConnections(db).length > 0, - accounts: accounts.filter((a) => a.state === 'ACTIVE' || a.state === 'NEW'), - newCount: accounts.filter((a) => a.state === 'NEW').length, + accounts: accounts.filter((a) => a.state === "ACTIVE" || a.state === "NEW"), + newCount: accounts.filter((a) => a.state === "NEW").length, connectionErrors: getConnectionErrors(db), staleAccounts: listStaleAccounts(db), lastSync: getLastSync(db), month, report: monthlyReport(db, month), pending: pendingStats(db, month), - recent: listLedger(db, { limit: RECENT_LIMIT }) + recent: listLedger(db, { limit: RECENT_LIMIT }), }; }; diff --git a/src/routes/(app)/accounts/+page.server.ts b/src/routes/(app)/accounts/+page.server.ts index 1b3a5c3..249debb 100644 --- a/src/routes/(app)/accounts/+page.server.ts +++ b/src/routes/(app)/accounts/+page.server.ts @@ -1,13 +1,13 @@ -import { fail } from '@sveltejs/kit'; -import { getDb } from '$lib/server/db'; +import { fail } from "@sveltejs/kit"; +import { getDb } from "$lib/server/db"; import { ACCOUNT_TYPES, + type AccountType, classifyAccount, listAccounts, setAccountHidden, - type AccountType -} from '$lib/server/services/accounts'; -import type { Actions, PageServerLoad } from './$types'; +} from "$lib/server/services/accounts"; +import type { Actions, PageServerLoad } from "./$types"; export const load: PageServerLoad = () => { return { accounts: listAccounts(getDb()), accountTypes: ACCOUNT_TYPES }; @@ -16,25 +16,25 @@ export const load: PageServerLoad = () => { export const actions: Actions = { classify: async ({ request }) => { const form = await request.formData(); - const id = String(form.get('id') ?? ''); - const accountType = String(form.get('type') ?? '') as AccountType; - const displayName = String(form.get('displayName') ?? '').trim() || null; + const id = String(form.get("id") ?? ""); + const accountType = String(form.get("type") ?? "") as AccountType; + const displayName = String(form.get("displayName") ?? "").trim() || null; if (!id || !ACCOUNT_TYPES.includes(accountType)) { - return fail(400, { message: 'Pick an account type.' }); + return fail(400, { message: "Pick an account type." }); } classifyAccount(getDb(), id, accountType, displayName); - return { message: 'Account classified.' }; + return { message: "Account classified." }; }, hide: async ({ request }) => { const form = await request.formData(); - setAccountHidden(getDb(), String(form.get('id') ?? ''), true); + setAccountHidden(getDb(), String(form.get("id") ?? ""), true); return {}; }, unhide: async ({ request }) => { const form = await request.formData(); - setAccountHidden(getDb(), String(form.get('id') ?? ''), false); + setAccountHidden(getDb(), String(form.get("id") ?? ""), false); return {}; - } + }, }; diff --git a/src/routes/(app)/ledger/+page.server.ts b/src/routes/(app)/ledger/+page.server.ts index d7906e2..def1b09 100644 --- a/src/routes/(app)/ledger/+page.server.ts +++ b/src/routes/(app)/ledger/+page.server.ts @@ -1,36 +1,47 @@ -import { fail } from '@sveltejs/kit'; -import { getDb } from '$lib/server/db'; -import { listLedger, listMonths, type LedgerFilters } from '$lib/server/services/ledger'; -import { listAccounts } from '$lib/server/services/accounts'; -import { listCategories } from '$lib/server/services/categories'; -import { categorizeManually, listEvents } from '$lib/server/services/categorization'; -import type { Actions, PageServerLoad } from './$types'; +import { fail } from "@sveltejs/kit"; +import { getDb } from "$lib/server/db"; +import { + type LedgerFilters, + listLedger, + listMonths, +} from "$lib/server/services/ledger"; +import { listAccounts } from "$lib/server/services/accounts"; +import { listCategories } from "$lib/server/services/categories"; +import { + categorizeManually, + listEvents, +} from "$lib/server/services/categorization"; +import type { Actions, PageServerLoad } from "./$types"; export const load: PageServerLoad = ({ url }) => { const db = getDb(); const filters: LedgerFilters = {}; - const account = url.searchParams.get('account'); + const account = url.searchParams.get("account"); if (account) filters.accountId = account; - const category = url.searchParams.get('category'); - if (category === 'uncategorized') filters.category = 'uncategorized'; + const category = url.searchParams.get("category"); + if (category === "uncategorized") filters.category = "uncategorized"; else if (category) filters.category = Number(category); - const month = url.searchParams.get('month'); + const month = url.searchParams.get("month"); if (month) filters.month = month; - const pending = url.searchParams.get('pending'); - if (pending === '1') filters.pending = true; - else if (pending === '0') filters.pending = false; - const q = url.searchParams.get('q')?.trim(); + const pending = url.searchParams.get("pending"); + if (pending === "1") filters.pending = true; + else if (pending === "0") filters.pending = false; + const q = url.searchParams.get("q")?.trim(); if (q) filters.q = q; - const source = url.searchParams.get('source'); - if (source === 'synced' || source === 'imported') filters.source = source; + const source = url.searchParams.get("source"); + if (source === "synced" || source === "imported") filters.source = source; - const historyId = url.searchParams.get('history'); + const historyId = url.searchParams.get("history"); let history: { id: number; events: ReturnType; /** Where the row itself came from — a different axis from who categorized it. */ - origin: { source: 'synced' | 'imported'; filename: string | null; importedAt: string | null }; + origin: { + source: "synced" | "imported"; + filename: string | null; + importedAt: string | null; + }; bankRecord: { description: string; payee: string | null; @@ -47,10 +58,10 @@ export const load: PageServerLoad = ({ url }) => { t.import_id, i.filename AS import_filename, i.committed_at AS imported_at FROM transactions t LEFT JOIN imports i ON i.id = t.import_id - WHERE t.id = ?` + WHERE t.id = ?`, ) .get(id) as - | { + | { description: string; payee: string | null; memo: string | null; @@ -59,25 +70,25 @@ export const load: PageServerLoad = ({ url }) => { import_id: number | null; import_filename: string | null; imported_at: string | null; - } - | undefined; + } + | undefined; history = { id, events: listEvents(db, id), origin: { - source: txn?.import_id == null ? 'synced' : 'imported', + source: txn?.import_id == null ? "synced" : "imported", filename: txn?.import_filename ?? null, - importedAt: txn?.imported_at ?? null + importedAt: txn?.imported_at ?? null, }, bankRecord: txn ? { - description: txn.description, - payee: txn.payee, - memo: txn.memo, - sfinId: txn.sfin_id, - extra: txn.extra - } - : null + description: txn.description, + payee: txn.payee, + memo: txn.memo, + sfinId: txn.sfin_id, + extra: txn.extra, + } + : null, }; } @@ -85,29 +96,29 @@ export const load: PageServerLoad = ({ url }) => { rows: listLedger(db, filters), months: listMonths(db), accounts: listAccounts(db) - .filter((a) => a.state !== 'HIDDEN') + .filter((a) => a.state !== "HIDDEN") .map(({ id, name, displayName }) => ({ id, label: displayName ?? name })), categories: listCategories(db, { activeOnly: true }), history, filters: { - account: account ?? '', - category: category ?? '', - month: month ?? '', - pending: pending ?? '', - source: source ?? '', - q: q ?? '' - } + account: account ?? "", + category: category ?? "", + month: month ?? "", + pending: pending ?? "", + source: source ?? "", + q: q ?? "", + }, }; }; export const actions: Actions = { categorize: async ({ request, locals }) => { const form = await request.formData(); - const transactionId = Number(form.get('transactionId')); - const raw = String(form.get('categoryId') ?? ''); - if (!transactionId) return fail(400, { message: 'Missing transaction.' }); - const categoryId = raw === '' ? null : Number(raw); + const transactionId = Number(form.get("transactionId")); + const raw = String(form.get("categoryId") ?? ""); + if (!transactionId) return fail(400, { message: "Missing transaction." }); + const categoryId = raw === "" ? null : Number(raw); categorizeManually(getDb(), transactionId, categoryId, locals.user!.did); return {}; - } + }, }; diff --git a/src/routes/(app)/reports/+page.server.ts b/src/routes/(app)/reports/+page.server.ts index 8e674bf..7c66980 100644 --- a/src/routes/(app)/reports/+page.server.ts +++ b/src/routes/(app)/reports/+page.server.ts @@ -1,17 +1,19 @@ -import { getDb } from '$lib/server/db'; -import { listMonths } from '$lib/server/services/ledger'; -import { monthlyReport, netWorthSeries } from '$lib/server/services/reports'; -import type { PageServerLoad } from './$types'; +import { getDb } from "$lib/server/db"; +import { listMonths } from "$lib/server/services/ledger"; +import { monthlyReport, netWorthSeries } from "$lib/server/services/reports"; +import type { PageServerLoad } from "./$types"; export const load: PageServerLoad = ({ url }) => { const db = getDb(); const months = listMonths(db); - const requested = url.searchParams.get('month'); - const month = requested && /^\d{4}-\d{2}$/.test(requested) ? requested : (months[0] ?? null); + const requested = url.searchParams.get("month"); + const month = requested && /^\d{4}-\d{2}$/.test(requested) + ? requested + : (months[0] ?? null); return { months, month, report: month ? monthlyReport(db, month) : null, - netWorth: netWorthSeries(db) + netWorth: netWorthSeries(db), }; }; diff --git a/src/routes/(app)/rules/+page.server.ts b/src/routes/(app)/rules/+page.server.ts index c887761..ac4d0ae 100644 --- a/src/routes/(app)/rules/+page.server.ts +++ b/src/routes/(app)/rules/+page.server.ts @@ -1,6 +1,6 @@ -import { fail } from '@sveltejs/kit'; -import { getDb } from '$lib/server/db'; -import { listCategories } from '$lib/server/services/categories'; +import { fail } from "@sveltejs/kit"; +import { getDb } from "$lib/server/db"; +import { listCategories } from "$lib/server/services/categories"; import { applyRulesToUncategorized, countRuleMatches, @@ -10,16 +10,16 @@ import { listRules, probeRule, ruleMatchHealth, - setRuleActive -} from '$lib/server/services/rules'; -import type { Actions, PageServerLoad } from './$types'; + setRuleActive, +} from "$lib/server/services/rules"; +import type { Actions, PageServerLoad } from "./$types"; /** Parse a signed decimal dollar string ("-15.49") into integer cents, or null. */ function parseAmountCents(raw: string): number | null { const m = raw.trim().match(/^(-?)\$?(\d+)(?:\.(\d{1,2}))?$/); if (!m) return null; - const cents = Number(m[2]) * 100 + Number((m[3] ?? '0').padEnd(2, '0')); - return m[1] === '-' ? -cents : cents; + const cents = Number(m[2]) * 100 + Number((m[3] ?? "0").padEnd(2, "0")); + return m[1] === "-" ? -cents : cents; } export const load: PageServerLoad = ({ url }) => { @@ -27,10 +27,10 @@ export const load: PageServerLoad = ({ url }) => { const categories = listCategories(db); const categoryNames = new Map(categories.map((c) => [c.id, c.name])); - const q = url.searchParams.get('q')?.trim() ?? ''; + const q = url.searchParams.get("q")?.trim() ?? ""; let rules = listRules(db).map((r) => ({ ...r, - categoryName: categoryNames.get(r.categoryId) ?? '?' + categoryName: categoryNames.get(r.categoryId) ?? "?", })); if (q) { const needle = q.toLowerCase(); @@ -38,24 +38,31 @@ export const load: PageServerLoad = ({ url }) => { (r) => r.pattern.toLowerCase().includes(needle) || (r.displayName?.toLowerCase().includes(needle) ?? false) || - r.categoryName.toLowerCase().includes(needle) + r.categoryName.toLowerCase().includes(needle), ); } - const detailId = Number(url.searchParams.get('rule')); + const detailId = Number(url.searchParams.get("rule")); let detail = null; if (detailId) { const rule = getRule(db, detailId); if (rule) { detail = { - rule: { ...rule, categoryName: categoryNames.get(rule.categoryId) ?? '?' }, + rule: { + ...rule, + categoryName: categoryNames.get(rule.categoryId) ?? "?", + }, fires: listRuleFires(db, rule.id), wouldHit: probeRule( db, - { matchType: rule.matchType, pattern: rule.pattern, amountCents: rule.amountCents }, - rule.id + { + matchType: rule.matchType, + pattern: rule.pattern, + amountCents: rule.amountCents, + }, + rule.id, ), - health: ruleMatchHealth(db, rule) + health: ruleMatchHealth(db, rule), }; } } @@ -64,27 +71,31 @@ export const load: PageServerLoad = ({ url }) => { rules, categories: listCategories(db, { activeOnly: true }), detail, - q + q, }; }; export const actions: Actions = { createRule: async ({ request, locals }) => { const form = await request.formData(); - const matchType = String(form.get('matchType')) as 'exact' | 'contains'; - const pattern = String(form.get('pattern') ?? '').trim(); - const categoryId = Number(form.get('categoryId')); - const displayName = String(form.get('displayName') ?? '').trim() || null; - const applyNow = form.get('applyNow') === 'on'; - if (!pattern || !['exact', 'contains'].includes(matchType) || !categoryId) { - return fail(400, { ruleMessage: 'Pattern, match type, and category are required.' }); + const matchType = String(form.get("matchType")) as "exact" | "contains"; + const pattern = String(form.get("pattern") ?? "").trim(); + const categoryId = Number(form.get("categoryId")); + const displayName = String(form.get("displayName") ?? "").trim() || null; + const applyNow = form.get("applyNow") === "on"; + if (!pattern || !["exact", "contains"].includes(matchType) || !categoryId) { + return fail(400, { + ruleMessage: "Pattern, match type, and category are required.", + }); } - const rawAmount = String(form.get('amount') ?? '').trim(); + const rawAmount = String(form.get("amount") ?? "").trim(); let amountCents: number | null = null; if (rawAmount) { amountCents = parseAmountCents(rawAmount); if (amountCents === null) { - return fail(400, { ruleMessage: 'Amount must look like -15.49 (signed dollars.cents).' }); + return fail(400, { + ruleMessage: "Amount must look like -15.49 (signed dollars.cents).", + }); } } @@ -96,25 +107,33 @@ export const actions: Actions = { amountCents, displayName, categoryId, - createdByDid: locals.user!.did + createdByDid: locals.user!.did, }); if (applyNow && matches > 0) { const applied = applyRulesToUncategorized(db, { ruleIds: [rule.id] }); return { - ruleMessage: `Rule created and applied to ${applied} existing uncategorized transaction${applied === 1 ? '' : 's'}.` + ruleMessage: + `Rule created and applied to ${applied} existing uncategorized transaction${ + applied === 1 ? "" : "s" + }.`, }; } return { - ruleMessage: - matches > 0 - ? `Rule created. It matches ${matches} existing uncategorized transaction${matches === 1 ? '' : 's'} — it was not applied to them (tick "apply to existing" to do that).` - : 'Rule created. New matching transactions will be categorized at sync.' + ruleMessage: matches > 0 + ? `Rule created. It matches ${matches} existing uncategorized transaction${ + matches === 1 ? "" : "s" + } — it was not applied to them (tick "apply to existing" to do that).` + : "Rule created. New matching transactions will be categorized at sync.", }; }, toggleRule: async ({ request }) => { const form = await request.formData(); - setRuleActive(getDb(), Number(form.get('id')), form.get('active') === 'true'); + setRuleActive( + getDb(), + Number(form.get("id")), + form.get("active") === "true", + ); return {}; - } + }, }; diff --git a/src/routes/(app)/settings/+page.server.ts b/src/routes/(app)/settings/+page.server.ts index 1cfe97a..9e02e5f 100644 --- a/src/routes/(app)/settings/+page.server.ts +++ b/src/routes/(app)/settings/+page.server.ts @@ -1,39 +1,48 @@ -import { fail } from '@sveltejs/kit'; -import { getDb } from '$lib/server/db'; -import { claimSetupToken, listConnections } from '$lib/server/services/connections'; -import { getLastSync } from '$lib/server/services/sync-status'; -import { runSync } from '$lib/server/services/sync'; +import { fail } from "@sveltejs/kit"; +import { getDb } from "$lib/server/db"; import { + claimSetupToken, + listConnections, +} from "$lib/server/services/connections"; +import { getLastSync } from "$lib/server/services/sync-status"; +import { runSync } from "$lib/server/services/sync"; +import { + type CategoryKind, createCategory, listCategories, renameCategory, setCategoryActive, - type CategoryKind -} from '$lib/server/services/categories'; -import { ClaimError } from '$lib/server/simplefin'; -import type { Actions, PageServerLoad } from './$types'; +} from "$lib/server/services/categories"; +import { ClaimError } from "$lib/server/simplefin"; +import type { Actions, PageServerLoad } from "./$types"; export const load: PageServerLoad = () => { const db = getDb(); return { - connections: listConnections(db).map(({ id, claimedAt }) => ({ id, claimedAt })), + connections: listConnections(db).map(({ id, claimedAt }) => ({ + id, + claimedAt, + })), lastSync: getLastSync(db), - categories: listCategories(db) + categories: listCategories(db), }; }; export const actions: Actions = { claim: async ({ request }) => { const form = await request.formData(); - const token = String(form.get('token') ?? '').trim(); - if (!token) return fail(400, { claimMessage: 'Paste a setup token first.' }); + const token = String(form.get("token") ?? "").trim(); + if (!token) { + return fail(400, { claimMessage: "Paste a setup token first." }); + } const db = getDb(); try { await claimSetupToken(db, token); } catch (err) { - const message = - err instanceof ClaimError ? err.message : 'Claiming failed. Check the token and try again.'; + const message = err instanceof ClaimError + ? err.message + : "Claiming failed. Check the token and try again."; return fail(400, { claimMessage: message }); } @@ -42,32 +51,38 @@ export const actions: Actions = { return { claimMessage: failed ? `Connected, but the first sync failed: ${failed.error}` - : 'Connected. First sync complete.' + : "Connected. First sync complete.", }; }, sync: async () => { const outcomes = await runSync(getDb()); if (outcomes.length === 0) { - return fail(400, { syncMessage: 'No SimpleFIN connection yet.' }); + return fail(400, { syncMessage: "No SimpleFIN connection yet." }); } const failed = outcomes.find((o) => !o.ok); - if (failed) return fail(502, { syncMessage: `Sync failed: ${failed.error}` }); + if (failed) { + return fail(502, { syncMessage: `Sync failed: ${failed.error}` }); + } const total = outcomes.reduce((sum, o) => sum + o.newTransactions, 0); - return { syncMessage: `Synced. ${total} new transaction${total === 1 ? '' : 's'}.` }; + return { + syncMessage: `Synced. ${total} new transaction${total === 1 ? "" : "s"}.`, + }; }, createCategory: async ({ request }) => { const form = await request.formData(); - const name = String(form.get('name') ?? ''); - const kind = String(form.get('kind') ?? '') as CategoryKind; - if (!['income', 'expense', 'transfer'].includes(kind)) { - return fail(400, { categoryMessage: 'Pick a kind.' }); + const name = String(form.get("name") ?? ""); + const kind = String(form.get("kind") ?? "") as CategoryKind; + if (!["income", "expense", "transfer"].includes(kind)) { + return fail(400, { categoryMessage: "Pick a kind." }); } try { createCategory(getDb(), name, kind); } catch (err) { - return fail(400, { categoryMessage: err instanceof Error ? err.message : 'Failed.' }); + return fail(400, { + categoryMessage: err instanceof Error ? err.message : "Failed.", + }); } return { categoryMessage: `Created "${name.trim()}".` }; }, @@ -75,20 +90,32 @@ export const actions: Actions = { renameCategory: async ({ request }) => { const form = await request.formData(); try { - renameCategory(getDb(), Number(form.get('id')), String(form.get('name') ?? '')); + renameCategory( + getDb(), + Number(form.get("id")), + String(form.get("name") ?? ""), + ); } catch (err) { - return fail(400, { categoryMessage: err instanceof Error ? err.message : 'Failed.' }); + return fail(400, { + categoryMessage: err instanceof Error ? err.message : "Failed.", + }); } - return { categoryMessage: 'Renamed.' }; + return { categoryMessage: "Renamed." }; }, toggleCategory: async ({ request }) => { const form = await request.formData(); try { - setCategoryActive(getDb(), Number(form.get('id')), form.get('active') === 'true'); + setCategoryActive( + getDb(), + Number(form.get("id")), + form.get("active") === "true", + ); } catch (err) { - return fail(400, { categoryMessage: err instanceof Error ? err.message : 'Failed.' }); + return fail(400, { + categoryMessage: err instanceof Error ? err.message : "Failed.", + }); } return {}; - } + }, }; diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index 6b33521..e6ecfc3 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -1,14 +1,14 @@

Settings

@@ -141,102 +141,102 @@ diff --git a/src/routes/(app)/settings/import/+page.server.ts b/src/routes/(app)/settings/import/+page.server.ts index ac580f8..8cec24d 100644 --- a/src/routes/(app)/settings/import/+page.server.ts +++ b/src/routes/(app)/settings/import/+page.server.ts @@ -1,15 +1,15 @@ -import { fail, redirect } from '@sveltejs/kit'; -import { getDb } from '$lib/server/db'; -import { listAccounts } from '$lib/server/services/accounts'; +import { fail, redirect } from "@sveltejs/kit"; +import { getDb } from "$lib/server/db"; +import { listAccounts } from "$lib/server/services/accounts"; import { createDraftImport, deleteDraftImport, listImports, saveMapping, - undoImport -} from '$lib/server/services/imports'; -import { detectMapping, parseCsv } from '$lib/server/services/csv-import'; -import type { Actions, PageServerLoad } from './$types'; + undoImport, +} from "$lib/server/services/imports"; +import { detectMapping, parseCsv } from "$lib/server/services/csv-import"; +import type { Actions, PageServerLoad } from "./$types"; /** Guards against a stray upload of something enormous or binary. */ const MAX_BYTES = 10 * 1024 * 1024; @@ -19,7 +19,7 @@ export const load: PageServerLoad = () => { return { // Accounts come only from sync; a CSV attaches to one, it never creates one. accounts: listAccounts(db) - .filter((a) => a.state !== 'HIDDEN') + .filter((a) => a.state !== "HIDDEN") .map(({ id, name, displayName }) => ({ id, label: displayName ?? name })), imports: listImports(db).map((record) => ({ id: record.id, @@ -29,23 +29,25 @@ export const load: PageServerLoad = () => { uploadedAt: record.uploadedAt, committedAt: record.committedAt, undoneAt: record.undoneAt, - rowCount: record.rowCount - })) + rowCount: record.rowCount, + })), }; }; export const actions: Actions = { upload: async ({ request }) => { const form = await request.formData(); - const accountId = String(form.get('accountId') ?? ''); - const file = form.get('file'); + const accountId = String(form.get("accountId") ?? ""); + const file = form.get("file"); - if (!accountId) return fail(400, { message: 'Choose an account for this file first.' }); + if (!accountId) { + return fail(400, { message: "Choose an account for this file first." }); + } if (!(file instanceof File) || file.size === 0) { - return fail(400, { message: 'Choose a CSV file to import.' }); + return fail(400, { message: "Choose a CSV file to import." }); } if (file.size > MAX_BYTES) { - return fail(400, { message: 'That file is larger than 10 MB.' }); + return fail(400, { message: "That file is larger than 10 MB." }); } const payload = await file.text(); @@ -56,7 +58,9 @@ export const actions: Actions = { // Archive first, parse second: a file that defeats the parser is still kept. importId = createDraftImport(db, accountId, file.name || null, payload); } catch (err) { - return fail(400, { message: err instanceof Error ? err.message : 'Upload failed.' }); + return fail(400, { + message: err instanceof Error ? err.message : "Upload failed.", + }); } // A best guess now saves a step; the mapping screen confirms it either way. @@ -72,23 +76,31 @@ export const actions: Actions = { undo: async ({ request }) => { const form = await request.formData(); - const importId = Number(form.get('importId')); + const importId = Number(form.get("importId")); try { const removed = undoImport(getDb(), importId); - return { message: `Undone. ${removed} transaction${removed === 1 ? '' : 's'} removed.` }; + return { + message: `Undone. ${removed} transaction${ + removed === 1 ? "" : "s" + } removed.`, + }; } catch (err) { - return fail(400, { message: err instanceof Error ? err.message : 'Undo failed.' }); + return fail(400, { + message: err instanceof Error ? err.message : "Undo failed.", + }); } }, cancel: async ({ request }) => { const form = await request.formData(); - const importId = Number(form.get('importId')); + const importId = Number(form.get("importId")); try { deleteDraftImport(getDb(), importId); - return { message: 'Discarded the unfinished import and its file.' }; + return { message: "Discarded the unfinished import and its file." }; } catch (err) { - return fail(400, { message: err instanceof Error ? err.message : 'Could not cancel.' }); + return fail(400, { + message: err instanceof Error ? err.message : "Could not cancel.", + }); } - } + }, }; diff --git a/src/routes/(app)/settings/import/+page.svelte b/src/routes/(app)/settings/import/+page.svelte index e34f794..f63caa1 100644 --- a/src/routes/(app)/settings/import/+page.svelte +++ b/src/routes/(app)/settings/import/+page.svelte @@ -1,13 +1,16 @@

Import CSV

@@ -105,117 +108,117 @@ diff --git a/src/routes/(app)/settings/import/[id]/+page.server.ts b/src/routes/(app)/settings/import/[id]/+page.server.ts index 25a01bc..318959f 100644 --- a/src/routes/(app)/settings/import/[id]/+page.server.ts +++ b/src/routes/(app)/settings/import/[id]/+page.server.ts @@ -1,59 +1,70 @@ -import { error, fail, redirect } from '@sveltejs/kit'; -import { getDb } from '$lib/server/db'; -import { listAccounts } from '$lib/server/services/accounts'; +import { error, fail, redirect } from "@sveltejs/kit"; +import { getDb } from "$lib/server/db"; +import { listAccounts } from "$lib/server/services/accounts"; import { commitImport, deleteDraftImport, getImport, previewImport, saveDecisions, - saveMapping -} from '$lib/server/services/imports'; -import { parseCsv, type CsvDateFormat, type CsvMapping } from '$lib/server/services/csv-import'; -import type { Actions, PageServerLoad } from './$types'; + saveMapping, +} from "$lib/server/services/imports"; +import { + type CsvDateFormat, + type CsvMapping, + parseCsv, +} from "$lib/server/services/csv-import"; +import type { Actions, PageServerLoad } from "./$types"; /** Enough to see a wrong column at a glance without rendering the whole file. */ const SAMPLE_ROWS = 5; function readMapping(form: FormData): CsvMapping { const value = (name: string) => { - const raw = String(form.get(name) ?? '').trim(); - return raw === '' ? null : raw; + const raw = String(form.get(name) ?? "").trim(); + return raw === "" ? null : raw; }; - const date = value('date'); - const description = value('description'); - if (!date) throw new Error('Choose the column holding the date.'); - if (!description) throw new Error('Choose the column holding the description.'); - - const amountMode = String(form.get('amountMode') ?? 'signed') as CsvMapping['amountMode']; - if (amountMode === 'signed' && !value('amount')) { - throw new Error('Choose the column holding the amount.'); + const date = value("date"); + const description = value("description"); + if (!date) throw new Error("Choose the column holding the date."); + if (!description) { + throw new Error("Choose the column holding the description."); + } + + const amountMode = String( + form.get("amountMode") ?? "signed", + ) as CsvMapping["amountMode"]; + if (amountMode === "signed" && !value("amount")) { + throw new Error("Choose the column holding the amount."); } - if (amountMode === 'debit-credit' && !value('debit') && !value('credit')) { - throw new Error('Choose a debit column, a credit column, or both.'); + if (amountMode === "debit-credit" && !value("debit") && !value("credit")) { + throw new Error("Choose a debit column, a credit column, or both."); } return { date, - transactedDate: value('transactedDate'), - dateFormat: (String(form.get('dateFormat') ?? 'mdy') as CsvDateFormat) ?? 'mdy', + transactedDate: value("transactedDate"), + dateFormat: (String(form.get("dateFormat") ?? "mdy") as CsvDateFormat) ?? + "mdy", amountMode, - amount: value('amount'), - debit: value('debit'), - credit: value('credit'), + amount: value("amount"), + debit: value("debit"), + credit: value("credit"), description, - payee: value('payee'), - memo: value('memo') + payee: value("payee"), + memo: value("memo"), }; } export const load: PageServerLoad = ({ params }) => { const db = getDb(); const record = getImport(db, Number(params.id)); - if (!record) error(404, 'No such import.'); + if (!record) error(404, "No such import."); const account = listAccounts(db).find((a) => a.id === record.accountId); - const accountLabel = account ? (account.displayName ?? account.name) : record.accountId; + const accountLabel = account + ? (account.displayName ?? account.name) + : record.accountId; const base = { id: record.id, @@ -61,17 +72,25 @@ export const load: PageServerLoad = ({ params }) => { status: record.status, accountId: record.accountId, accountLabel, - committedAt: record.committedAt + committedAt: record.committedAt, }; // A committed or undone import is a record, not a wizard. - if (record.status !== 'draft') { + if (record.status !== "draft") { const counts = db .prepare( - 'SELECT COUNT(*) AS n FROM transactions WHERE import_id = ? AND removed_at IS NULL' + "SELECT COUNT(*) AS n FROM transactions WHERE import_id = ? AND removed_at IS NULL", ) .get(record.id) as { n: number }; - return { ...base, headers: [], mapping: null, sample: [], preview: null, parseError: null, rowCount: counts.n }; + return { + ...base, + headers: [], + mapping: null, + sample: [], + preview: null, + parseError: null, + rowCount: counts.n, + }; } let headers: string[] = []; @@ -79,7 +98,9 @@ export const load: PageServerLoad = ({ params }) => { try { headers = parseCsv(record.payload).headers; } catch (err) { - parseError = err instanceof Error ? err.message : 'This file could not be read as CSV.'; + parseError = err instanceof Error + ? err.message + : "This file could not be read as CSV."; } let preview = null; @@ -96,20 +117,22 @@ export const load: PageServerLoad = ({ params }) => { sample: full.classified.slice(0, SAMPLE_ROWS).map((c) => ({ posted: c.candidate.posted, amountCents: c.candidate.amountCents, - description: c.candidate.description + description: c.candidate.description, })), flagged: full.classified - .filter((c) => c.status === 'flagged') + .filter((c) => c.status === "flagged") .map((c) => ({ syntheticId: c.candidate.syntheticId, posted: c.candidate.posted, amountCents: c.candidate.amountCents, description: c.candidate.description, - match: c.match - })) + match: c.match, + })), }; } catch (err) { - parseError = err instanceof Error ? err.message : 'This file could not be read as CSV.'; + parseError = err instanceof Error + ? err.message + : "This file could not be read as CSV."; } } @@ -119,7 +142,7 @@ export const load: PageServerLoad = ({ params }) => { mapping: record.mapping, preview, parseError, - rowCount: 0 + rowCount: 0, }; }; @@ -129,18 +152,22 @@ export const actions: Actions = { try { saveMapping(getDb(), Number(params.id), readMapping(form)); } catch (err) { - return fail(400, { message: err instanceof Error ? err.message : 'Mapping failed.' }); + return fail(400, { + message: err instanceof Error ? err.message : "Mapping failed.", + }); } - return { message: 'Mapping updated.' }; + return { message: "Mapping updated." }; }, cancel: async ({ params }) => { try { deleteDraftImport(getDb(), Number(params.id)); } catch (err) { - return fail(400, { message: err instanceof Error ? err.message : 'Could not cancel.' }); + return fail(400, { + message: err instanceof Error ? err.message : "Could not cancel.", + }); } - redirect(303, '/settings/import'); + redirect(303, "/settings/import"); }, commit: async ({ request, params }) => { @@ -150,19 +177,31 @@ export const actions: Actions = { // Only checked rows are kept. An unchecked (or absent) flagged row is skipped: // where both sources claim a transaction the synced row is the better record. - const decisions: Record = {}; - for (const id of form.getAll('keep')) decisions[String(id)] = 'keep'; + const decisions: Record = {}; + for (const id of form.getAll("keep")) decisions[String(id)] = "keep"; try { saveDecisions(db, importId, decisions); const result = commitImport(db, importId); const parts = [`Imported ${result.inserted + result.revived}`]; - if (result.skipped) parts.push(`skipped ${result.skipped} possible duplicate${result.skipped === 1 ? '' : 's'}`); - if (result.alreadyPresent) parts.push(`${result.alreadyPresent} already present`); - if (result.ruleCategorized) parts.push(`${result.ruleCategorized} categorized by rules`); - return { message: `${parts.join(', ')}.` }; + if (result.skipped) { + parts.push( + `skipped ${result.skipped} possible duplicate${ + result.skipped === 1 ? "" : "s" + }`, + ); + } + if (result.alreadyPresent) { + parts.push(`${result.alreadyPresent} already present`); + } + if (result.ruleCategorized) { + parts.push(`${result.ruleCategorized} categorized by rules`); + } + return { message: `${parts.join(", ")}.` }; } catch (err) { - return fail(400, { message: err instanceof Error ? err.message : 'Import failed.' }); + return fail(400, { + message: err instanceof Error ? err.message : "Import failed.", + }); } - } + }, }; diff --git a/src/routes/client-metadata.json/+server.ts b/src/routes/client-metadata.json/+server.ts index 31baff1..929c9e2 100644 --- a/src/routes/client-metadata.json/+server.ts +++ b/src/routes/client-metadata.json/+server.ts @@ -1,5 +1,5 @@ -import { json } from '@sveltejs/kit'; -import { getOAuthClient } from '$lib/server/auth/oauth-client'; +import { json } from "@sveltejs/kit"; +import { getOAuthClient } from "$lib/server/auth/oauth-client"; export function GET() { return json(getOAuthClient().clientMetadata); diff --git a/src/routes/healthz/+server.ts b/src/routes/healthz/+server.ts index 60261a6..6a2a95c 100644 --- a/src/routes/healthz/+server.ts +++ b/src/routes/healthz/+server.ts @@ -1,4 +1,4 @@ -import { json } from '@sveltejs/kit'; +import { json } from "@sveltejs/kit"; export function GET() { return json({ ok: true }); diff --git a/src/routes/jwks.json/+server.ts b/src/routes/jwks.json/+server.ts index f7ec6be..d9e5438 100644 --- a/src/routes/jwks.json/+server.ts +++ b/src/routes/jwks.json/+server.ts @@ -1,5 +1,5 @@ -import { json } from '@sveltejs/kit'; -import { getOAuthClient } from '$lib/server/auth/oauth-client'; +import { json } from "@sveltejs/kit"; +import { getOAuthClient } from "$lib/server/auth/oauth-client"; export function GET() { return json(getOAuthClient().jwks); diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 6056ed4..8321c2b 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -1,14 +1,17 @@ -import { fail, redirect } from '@sveltejs/kit'; -import { getOAuthClient } from '$lib/server/auth/oauth-client'; -import type { Actions } from './$types'; +import { fail, redirect } from "@sveltejs/kit"; +import { getOAuthClient } from "$lib/server/auth/oauth-client"; +import type { Actions } from "./$types"; export const actions: Actions = { default: async ({ request }) => { const form = await request.formData(); - const raw = String(form.get('handle') ?? ''); - const handle = raw.trim().replace(/^@/, '').toLowerCase(); + const raw = String(form.get("handle") ?? ""); + const handle = raw.trim().replace(/^@/, "").toLowerCase(); if (!handle) { - return fail(400, { handle: raw, message: 'Enter your handle to sign in.' }); + return fail(400, { + handle: raw, + message: "Enter your handle to sign in.", + }); } let authUrl: URL; @@ -16,15 +19,16 @@ export const actions: Actions = { // The user-entered handle rides along as app state so the callback can // store it without a second resolution. authUrl = await getOAuthClient().authorize(handle, { - state: JSON.stringify({ handle }) + state: JSON.stringify({ handle }), }); } catch { return fail(400, { handle: raw, - message: `Couldn't find an account for "${handle}". Check the handle and try again.` + message: + `Couldn't find an account for "${handle}". Check the handle and try again.`, }); } redirect(302, authUrl.toString()); - } + }, }; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index cb059ff..359cacc 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -1,8 +1,8 @@
@@ -43,49 +43,49 @@
diff --git a/src/routes/logout/+server.ts b/src/routes/logout/+server.ts index d557e66..df0018c 100644 --- a/src/routes/logout/+server.ts +++ b/src/routes/logout/+server.ts @@ -1,12 +1,12 @@ -import { redirect } from '@sveltejs/kit'; -import { getDb } from '$lib/server/db'; -import { deleteSession } from '$lib/server/services/sessions'; -import { SESSION_COOKIE } from '../../hooks.server'; -import type { RequestHandler } from './$types'; +import { redirect } from "@sveltejs/kit"; +import { getDb } from "$lib/server/db"; +import { deleteSession } from "$lib/server/services/sessions"; +import { SESSION_COOKIE } from "../../hooks.server"; +import type { RequestHandler } from "./$types"; export const POST: RequestHandler = ({ cookies }) => { const token = cookies.get(SESSION_COOKIE); if (token) deleteSession(getDb(), token); - cookies.delete(SESSION_COOKIE, { path: '/' }); - redirect(303, '/login'); + cookies.delete(SESSION_COOKIE, { path: "/" }); + redirect(303, "/login"); }; diff --git a/src/routes/oauth/callback/+server.ts b/src/routes/oauth/callback/+server.ts index 47792dd..c49de5b 100644 --- a/src/routes/oauth/callback/+server.ts +++ b/src/routes/oauth/callback/+server.ts @@ -1,11 +1,11 @@ -import { error, redirect } from '@sveltejs/kit'; -import { getConfig } from '$lib/server/config'; -import { getDb } from '$lib/server/db'; -import { getOAuthClient } from '$lib/server/auth/oauth-client'; -import { upsertUser } from '$lib/server/services/users'; -import { createSession } from '$lib/server/services/sessions'; -import { SESSION_COOKIE } from '../../../hooks.server'; -import type { RequestHandler } from './$types'; +import { error, redirect } from "@sveltejs/kit"; +import { getConfig } from "$lib/server/config"; +import { getDb } from "$lib/server/db"; +import { getOAuthClient } from "$lib/server/auth/oauth-client"; +import { upsertUser } from "$lib/server/services/users"; +import { createSession } from "$lib/server/services/sessions"; +import { SESSION_COOKIE } from "../../../hooks.server"; +import type { RequestHandler } from "./$types"; export const GET: RequestHandler = async ({ url, cookies }) => { let did: string; @@ -15,18 +15,20 @@ export const GET: RequestHandler = async ({ url, cookies }) => { did = result.session.did; appState = result.state; } catch { - redirect(303, '/login'); + redirect(303, "/login"); } const config = getConfig(); if (!config.allowedDids.includes(did)) { // Not authorized: no session, no user record. - error(403, 'This account is not authorized to use this instance.'); + error(403, "This account is not authorized to use this instance."); } let handle = did; try { - const parsed = appState ? (JSON.parse(appState) as { handle?: string }) : null; + const parsed = appState + ? (JSON.parse(appState) as { handle?: string }) + : null; if (parsed?.handle) handle = parsed.handle; } catch { // state was not ours to parse; fall back to the DID @@ -36,12 +38,12 @@ export const GET: RequestHandler = async ({ url, cookies }) => { upsertUser(db, did, handle); const { token, expiresAt } = createSession(db, did); cookies.set(SESSION_COOKIE, token, { - path: '/', + path: "/", httpOnly: true, - sameSite: 'lax', - secure: config.appUrl.startsWith('https:'), - expires: expiresAt + sameSite: "lax", + secure: config.appUrl.startsWith("https:"), + expires: expiresAt, }); - redirect(303, '/'); + redirect(303, "/"); }; diff --git a/vite.config.ts b/vite.config.ts index b213906..27de72d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,6 @@ -import adapter from '@sveltejs/adapter-node'; -import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; +import adapter from "@sveltejs/adapter-node"; +import { sveltekit } from "@sveltejs/kit/vite"; +import { defineConfig } from "vite"; export default defineConfig({ plugins: [ @@ -8,11 +8,11 @@ export default defineConfig({ compilerOptions: { // Force runes mode for the project, except for libraries. Can be removed in svelte 6. runes: ({ filename }) => - filename.split(/[/\\]/).includes('node_modules') ? undefined : true + filename.split(/[/\\]/).includes("node_modules") ? undefined : true, }, // adapter-node output runs under Deno in production (deno task start) - adapter: adapter() - }) - ] + adapter: adapter(), + }), + ], });