Add server-persisted entry drafts (snap-and-finish-later workflow) #21

Open
opened 2026-05-19 12:36:24 +00:00 by padreug · 0 comments
Owner

Summary

Let users save a partial entry — expense, income, or asset purchase — and complete it later, with the date and BTC/fiat price locked at the moment of capture rather than at submission time. The workflow people actually use: "I bought groceries for €40 just now but I don't have time to fill in the description, account, etc. — let me snap a draft and finish it tonight." Optionally attach a receipt photo at the same time (see #11 for attachments).

The webapp already has a localStorage-only draft notion. That's fragile (lost on browser clear, doesn't cross devices, can't be resumed in the LNbits ext UI). This issue proposes promoting drafts to a real server-persisted resource.

Why the price snapshot matters

Without snapshotting at capture time, sats cost basis drifts to whenever the user happens to finish the draft. Example: user spends €40 at lunch when 1 BTC = €100k (so €40 ≈ 40,000 sats), opens the draft that evening when 1 BTC = €90k (so €40 ≈ 44,444 sats), and the submission records the wrong sats equivalent. That ~10% drift is small for groceries, large for a 24-hour delay during volatility, and huge over multi-day deferrals. The cost-basis-at-the-time-of-the-event is the auditable truth; the cost-basis-at-the-time-the-user-finished-typing is not.

So the draft needs to capture at minimum:

  • captured_at — wall-clock timestamp (server side, immutable).
  • btc_price_snapshot — sats-per-fiat-unit, per supported fiat currency. Pulled from the same exchange-rate source the entry endpoints use.

Everything else (description, account, exact amount) is filled in optionally and edited freely until the user submits.

Proposed data model

New table in the libra extension DB (Fava holds finalized entries; drafts are pre-Fava):

class EntryDraft(BaseModel):
    id: str
    user_id: str
    entry_type: Literal["expense", "income", "asset"]
    captured_at: datetime           # locked at draft creation
    btc_price_snapshot: Decimal     # sats per fiat unit at captured_at
    fiat_currency: str              # captured at draft creation alongside the price

    # Partial fields, all optional — user fills in over time
    description: Optional[str] = None
    fiat_amount: Optional[Decimal] = None
    account: Optional[str] = None   # expense/income/asset account name
    is_equity: Optional[bool] = None  # only for expense type
    reference: Optional[str] = None
    attachments: Optional[list[str]] = None  # URL list; backed by #11

    created_at: datetime
    updated_at: datetime

A separate entry_drafts table mirrors the per-table pattern already in migrations.py.

Proposed endpoints

Method Path Purpose
POST /api/v1/drafts Create — requires entry_type and fiat_currency; server fills captured_at and btc_price_snapshot.
GET /api/v1/drafts List the calling user's drafts (most recent first).
GET /api/v1/drafts/{id} Single draft (must belong to caller).
PATCH /api/v1/drafts/{id} Update mutable fields; captured_at and btc_price_snapshot stay locked.
DELETE /api/v1/drafts/{id} Discard.
POST /api/v1/drafts/{id}/submit Finalize: validate required fields per entry_type, forward to the matching /entries/expense / /entries/income / /entries/asset flow using the draft's captured price, then delete the draft on success.

Auth: require_invoice_key (same as the entry endpoints). Drafts are per-user; one user can't see or touch another user's drafts.

Proposed frontend (webapp and LNbits ext)

  • Save Draft action in each entry dialog (Add Expense, Add Income, Add Asset) alongside Submit. Pressing it creates/updates the draft and closes the dialog.
  • Drafts section on the Record page listing the user's open drafts with a short preview ("Groceries €40 — May 18, locked at 1 BTC = €100,123"). Tap to resume → opens the matching dialog pre-filled.
  • Auto-save: while a dialog is open and the user has typed anything, periodically PATCH the draft (or on blur / dialog close). Saves are a happy path that don't lock the UI.
  • A draft badge somewhere visible from the home view so a draft doesn't get forgotten.

Composition with #11 (attachments)

Once #11 lands, drafts get an attachments: list[str] field that's populated via the same image upload flow as final entries. On submit, the attachments carry over into the finalized entry. Until #11 lands, drafts are text-only and the user adds the receipt photo at submission time on a final entry — degraded but workable.

The dependency is one-directional: #11 doesn't need drafts; drafts works without #11 (just no photo). They ship independently and compose.

Open questions

  1. Draft TTL. Auto-delete drafts older than N days? Useful to avoid stale-price-snapshot drafts haunting the list, but risks losing a user's in-progress work. Suggest 30-day soft expiry with a visible "expires in N days" hint, or no expiry but a "stale price" warning past 24h.
  2. Price snapshot scope. A draft captures one (currency, price) pair at creation. If the user changes their mind about which currency the transaction was in after capture, should they have to discard and start a new draft? Probably yes — changing currency mid-draft invalidates the snapshot's whole point.
  3. Multiple drafts vs single in-progress. Hard cap or unlimited? A user could plausibly have 5–10 unrelated drafts; a hard cap reads like an arbitrary limit, but unbounded growth is a latent DoS. Suggest soft-limit at ~20 with a UI nudge to clean up, no hard cap.
  4. Auto-save cadence. Every keystroke (debounced) vs on blur vs explicit Save Draft button. Mobile context (where this feature shines) often has flaky network — auto-save with retry probably wins. PATCH endpoint needs to be cheap.
  5. Edit vs delete-and-recreate. Should the /submit endpoint accept payload overrides that take precedence over the stored draft fields? Probably no — that obscures the "draft as snapshot" mental model. If a user wants different values they should PATCH first.
  6. Drafts in the Pending Approvals admin view. Should super-users see all users' drafts? Probably no — drafts are private until submitted. Admin only sees the entry once it goes through /submit and becomes a pending entry.
  7. What about drafts for entries that don't have a user-facing endpoint? Receivable, revenue (admin endpoints) — almost certainly out of scope; drafts is a user-quality-of-life feature, admins doing direct entries don't need it.

Severity / priority

Quality-of-life and correctness — drafts is the difference between accurate same-day expense tracking and "I'll get to it later" entropy. The price snapshot in particular addresses a real correctness bug for any non-instant submission. Cost is moderate: new table, ~5 endpoints, a UI section on Record, dialog plumbing for Save Draft / Resume.

Related: #11 (receipt attachments — drafts will use the same attachment mechanism), #20 (asset purchase entry — third entry type that needs draft support).

## Summary Let users save a partial entry — expense, income, or asset purchase — and complete it later, with the **date and BTC/fiat price locked at the moment of capture** rather than at submission time. The workflow people actually use: "I bought groceries for €40 just now but I don't have time to fill in the description, account, etc. — let me snap a draft and finish it tonight." Optionally attach a receipt photo at the same time (see #11 for attachments). The webapp already has a localStorage-only draft notion. That's fragile (lost on browser clear, doesn't cross devices, can't be resumed in the LNbits ext UI). This issue proposes promoting drafts to a real server-persisted resource. ## Why the price snapshot matters Without snapshotting at capture time, sats cost basis drifts to whenever the user happens to finish the draft. Example: user spends €40 at lunch when 1 BTC = €100k (so €40 ≈ 40,000 sats), opens the draft that evening when 1 BTC = €90k (so €40 ≈ 44,444 sats), and the submission records the wrong sats equivalent. That ~10% drift is small for groceries, large for a 24-hour delay during volatility, and *huge* over multi-day deferrals. The cost-basis-at-the-time-of-the-event is the auditable truth; the cost-basis-at-the-time-the-user-finished-typing is not. So the draft needs to capture at minimum: - `captured_at` — wall-clock timestamp (server side, immutable). - `btc_price_snapshot` — sats-per-fiat-unit, per supported fiat currency. Pulled from the same exchange-rate source the entry endpoints use. Everything else (description, account, exact amount) is filled in optionally and edited freely until the user submits. ## Proposed data model New table in the libra extension DB (Fava holds finalized entries; drafts are pre-Fava): ```python class EntryDraft(BaseModel): id: str user_id: str entry_type: Literal["expense", "income", "asset"] captured_at: datetime # locked at draft creation btc_price_snapshot: Decimal # sats per fiat unit at captured_at fiat_currency: str # captured at draft creation alongside the price # Partial fields, all optional — user fills in over time description: Optional[str] = None fiat_amount: Optional[Decimal] = None account: Optional[str] = None # expense/income/asset account name is_equity: Optional[bool] = None # only for expense type reference: Optional[str] = None attachments: Optional[list[str]] = None # URL list; backed by #11 created_at: datetime updated_at: datetime ``` A separate `entry_drafts` table mirrors the per-table pattern already in `migrations.py`. ## Proposed endpoints | Method | Path | Purpose | |---|---|---| | `POST` | `/api/v1/drafts` | Create — requires `entry_type` and `fiat_currency`; server fills `captured_at` and `btc_price_snapshot`. | | `GET` | `/api/v1/drafts` | List the calling user's drafts (most recent first). | | `GET` | `/api/v1/drafts/{id}` | Single draft (must belong to caller). | | `PATCH`| `/api/v1/drafts/{id}` | Update mutable fields; `captured_at` and `btc_price_snapshot` stay locked. | | `DELETE`| `/api/v1/drafts/{id}` | Discard. | | `POST` | `/api/v1/drafts/{id}/submit` | Finalize: validate required fields per `entry_type`, forward to the matching `/entries/expense` / `/entries/income` / `/entries/asset` flow using the draft's captured price, then delete the draft on success. | Auth: `require_invoice_key` (same as the entry endpoints). Drafts are per-user; one user can't see or touch another user's drafts. ## Proposed frontend (webapp and LNbits ext) - **Save Draft** action in each entry dialog (Add Expense, Add Income, Add Asset) alongside Submit. Pressing it creates/updates the draft and closes the dialog. - **Drafts section** on the Record page listing the user's open drafts with a short preview ("Groceries €40 — May 18, locked at 1 BTC = €100,123"). Tap to resume → opens the matching dialog pre-filled. - **Auto-save**: while a dialog is open and the user has typed anything, periodically `PATCH` the draft (or on blur / dialog close). Saves are a happy path that don't lock the UI. - A **draft badge** somewhere visible from the home view so a draft doesn't get forgotten. ## Composition with #11 (attachments) Once #11 lands, drafts get an `attachments: list[str]` field that's populated via the same image upload flow as final entries. On submit, the attachments carry over into the finalized entry. Until #11 lands, drafts are text-only and the user adds the receipt photo at submission time on a final entry — degraded but workable. The dependency is one-directional: #11 doesn't need drafts; drafts works without #11 (just no photo). They ship independently and compose. ## Open questions 1. **Draft TTL.** Auto-delete drafts older than N days? Useful to avoid stale-price-snapshot drafts haunting the list, but risks losing a user's in-progress work. Suggest 30-day soft expiry with a visible "expires in N days" hint, or no expiry but a "stale price" warning past 24h. 2. **Price snapshot scope.** A draft captures one `(currency, price)` pair at creation. If the user changes their mind about which currency the transaction was in *after* capture, should they have to discard and start a new draft? Probably yes — changing currency mid-draft invalidates the snapshot's whole point. 3. **Multiple drafts vs single in-progress.** Hard cap or unlimited? A user could plausibly have 5–10 unrelated drafts; a hard cap reads like an arbitrary limit, but unbounded growth is a latent DoS. Suggest soft-limit at ~20 with a UI nudge to clean up, no hard cap. 4. **Auto-save cadence.** Every keystroke (debounced) vs on blur vs explicit Save Draft button. Mobile context (where this feature shines) often has flaky network — auto-save with retry probably wins. PATCH endpoint needs to be cheap. 5. **Edit vs delete-and-recreate.** Should the `/submit` endpoint accept payload overrides that take precedence over the stored draft fields? Probably no — that obscures the "draft as snapshot" mental model. If a user wants different values they should PATCH first. 6. **Drafts in the Pending Approvals admin view.** Should super-users see all users' drafts? Probably no — drafts are private until submitted. Admin only sees the entry once it goes through `/submit` and becomes a pending entry. 7. **What about drafts for entries that don't have a user-facing endpoint?** Receivable, revenue (admin endpoints) — almost certainly out of scope; drafts is a user-quality-of-life feature, admins doing direct entries don't need it. ## Severity / priority Quality-of-life and correctness — drafts is the difference between accurate same-day expense tracking and "I'll get to it later" entropy. The price snapshot in particular addresses a real correctness bug for any non-instant submission. Cost is moderate: new table, ~5 endpoints, a UI section on Record, dialog plumbing for Save Draft / Resume. Related: #11 (receipt attachments — drafts will use the same attachment mechanism), #20 (asset purchase entry — third entry type that needs draft support).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/libra#21
No description provided.