Part of the Sanchayam series.

What

The ledger is Sanchayam’s expense and income tracker. Every entry can carry supporting documents stored in S3: receipts, invoices, settlement reports. The first version of the attachment panel accepted one file per trip through the file picker. Adding five receipts to one entry meant opening the picker five times and waiting for each upload to finish before starting the next.

The panel now accepts a whole batch in one pick. This post covers how that works: per-file queues, sequential uploads, up-front validation, and retryable failures.

Why the Backend Needed No Change

The server-side attachment flow was already per file, three steps per file:

  1. POST /ledger/:id/attachments/upload-url returns a presigned S3 PUT URL and an s3_key.
  2. The browser PUTs the file directly to S3 with that URL.
  3. POST /ledger/:id/attachments confirms the upload and persists the ledger_attachments row.

Because the flow was already per file, calling it N times in a row is a valid batch. Nothing on the server changed. The entire feature is frontend work in Ledger.tsx.

const MAX_FILE_SIZE  = 10 * 1024 * 1024   // 10 MB
const MAX_PER_ENTRY  = 20

The allowed types are PDF, JPG, PNG, WEBP, XLS, and XLSX. Spreadsheets joined later, because a consulting ledger keeps settlement reports as xlsx, not just scans.

The Queue

The old state was a single uploadingFor entry id and a single uploadError string. One file at a time meant one status at a time. A batch needs one status per file.

The new state is a queue per entry:

interface UploadItem {
  key: string
  file: File
  status: 'pending' | 'uploading' | 'done' | 'error'
  error?: string
}

const [uploadQueue, setUploadQueue] = useState<Record<string, UploadItem[]>>({})

Each file carries its own outcome. One failure in the middle of ten files is reported against that file, not against the batch. Failed files get a Retry that re-runs just that file, and a Dismiss. The File object is kept on the queue item so a retry does not ask the user to pick the file again.

A batch-level message is kept separate from the per-file rows. “5 files skipped, only 3 slots left” belongs to the batch, not to any one file.

const [uploadNotice, setUploadNotice] = useState<Record<string, string | null>>({})

Validate the Whole Batch Up Front

handleFileSelected reads the entire FileList, not files[0]. Everything that can be judged before touching the network is judged in one pass:

  • unsupported type
  • over 10 MB
  • beyond the remaining slot count

All rejections are reported together as one notice before any upload starts. The user is told which files will not be sent and why, instead of discovering it eight files into a long run.

The slot count deserves attention. It is not just MAX_PER_ENTRY - committed. It also subtracts anything already queued:

const slots = MAX_PER_ENTRY - (attachmentsMap[entryId] ?? []).length - inFlight(entryId)

inFlight counts pending and uploading items. Without that subtraction, two batches started back to back could together push an entry over the cap, because the server-side check on upload-url would only see the committed rows.

Sequential Uploads

Uploads run one file at a time, in order. This is a deliberate decision, not a simplification. Two things depend on it:

The progress counter stays exact. A per-file “3 of 8” counter is only true if no two uploads are ever in flight together.

The server-side cap check stays correct. upload-url rejects when the entry already has 20 committed attachments. If two requests raced with 19 committed, both would pass the check. Sequential uploads make that race impossible.

async function runQueue(entryId: string, items: UploadItem[]) {
  for (const item of items) {
    setUploadQueue(q => patchItem(q, entryId, item.key, { status: 'uploading', error: undefined }))
    try {
      await uploadOne(entryId, item.file)
      setUploadQueue(q => patchItem(q, entryId, item.key, { status: 'done' }))
    } catch (err) {
      setUploadQueue(q => patchItem(q, entryId, item.key, { status: 'error', error: msg }))
    }
  }
}

uploadOne does the three steps: sign, PUT to S3, confirm. It throws on any step, so runQueue marks just that item failed and carries on to the next file.

Collapsing the Row Mid-Batch

Two details that took thought:

The batch survives collapsing the row. The queue is keyed by entry id, and the upload loop takes the entry id as a captured argument rather than reading the currently expanded row. Collapsing an entry mid batch keeps the run going, and its failures are still waiting when the row is reopened.

Successes are dropped, failures stay. After a batch settles, the done items are removed from the queue. A file that landed is already visible in the attachment list above, so its queue row would be saying the same thing twice. Only the failed items remain, each with its Retry.

The camera input stays single shot. A capture produces one photo, so the multiple attribute only went on the file input.


The panel hint also got corrected while we were in there. It advertised only PDF, JPG, PNG, and WEBP while both the page and the server had accepted XLS and XLSX for a while. It now lists all six and shows the remaining slot count.