Skip to content

Budgets: store payment files with the private status and delete them with their request - #2086

Merged
mcliwanow merged 12 commits into
productionfrom
budgets/delete-files-with-their-request
Sep 22, 2026
Merged

mcliwanow merged 12 commits into
productionfrom
budgets/delete-files-with-their-request

Conversation

@mcliwanow

Copy link
Copy Markdown
Contributor

Why

A budget request's files (the invoices and receipts on a reimbursement or vendor payment request) are classified by their parent. privacy.php decides who may see a file by resolving its post_parent to a live request, and the attachment itself is stored as inherit, like any other upload. So the file's classification lives on a different row than the file, and it holds only as long as that row does.

wp_delete_post() doesn't delete a post's attachments, it points them at the deleted post's own parent, which is 0 for these types, and from then on they are ordinary unattached media. That happens more often than a manual delete suggests: Core purges auto-draft posts older than 7 days, and the Files metabox uploads against the auto-draft the Add New screen opens with, so a request that is never saved leaves its files behind a week later. Cloning a site for a new year copies the media library without the requests and does the same thing in bulk. A census from the sandbox confirmed both shapes exist on the network.

The fix is the same one #1992 applies to sponsor agreements: record what a file is on the file itself, as it is created, and store it private so it doesn't inherit whatever its parent happens to be. The parent rule stays only for files created before this change, until a one-time backfill has marked them.

Two smaller things in the same code. Files on an auto-draft request are missed by the second-pass rules, because get_payment_file_parent_ids() looks requests up with post_status => 'any', which excludes auto-draft. And wp_ajax_get_attachment() resolves by ID and checks upload_files alone, so the media details it hands to JavaScript were never run through the rules the XML-RPC ones already were.

What changed

  • A request's files are deleted with the request. before_delete_post on both request types, so Delete Permanently, the trash purge and the auto-draft purge all take the files. Trashing keeps them, and restoring brings them back attached.
  • A file uploaded onto a request is stored private, with a _wcorg_budget_file marker and the random suffix, in the request that creates it (wp_unique_filename, wp_insert_attachment_data, add_attachment). An auto-draft request counts.
  • A file attached to a request later, through the Files metabox field or the Media Library's Attach action, gets the marker and the status as it is reparented.
  • privacy.php reads the marker first and falls back to the parent for files created before this change. read_post is decided alongside edit_post, since private maps it to read_private_posts, which every organizer has. The readers are unchanged: uploader, requester, site admin.
  • wp_prepare_attachment_for_js runs through the same redaction as xmlrpc_prepare_media_item.
  • The Files metabox names the statuses it queries, so a requester who is only an Author still sees a co-organizer's private upload on their request (WP_Query would otherwise gate it on read_private_posts).
  • The SQL guard in exclude_others_payment_files() is two LEFT JOINs instead of a correlated NOT IN subquery.
  • A one-time WP-CLI command, wp wc-budget-files scan and backfill, gives existing files the marker and the status. Database only, one site per run, safe to re-run. scan walks wp_blogs by table prefix without loading sites and prints a ready-to-run backfill line per site, with the copies it matched across sites. backfill writes _wcorg_budget_file_backfilled on each file and _wcorg_budget_file_delete on copies, as work lists for later steps; it deletes nothing. The command and its tests get removed after the run.

Nothing here renames or moves a file, and nothing touches the sponsor agreement work in #1992.

Testing

20 new tests in --group budgets, 60 in the group, all passing. The three cascade paths (manual delete, trash purge, auto-draft purge), both attach routes and both upload cases fail against production and pass with the change; trash-and-restore and "an ordinary post is left as Core leaves it" pass both ways by design.

Compatibility: an upload against an ordinary post is untouched (inherit, no marker, no suffix). A co-organizer's upload on an Author's request stays visible to that Author and the submit check still passes. A file from before this change, inherit and unmarked, is still scoped by its parent. Deleting a request as a co-organizer who can't see another organizer's upload on it still takes that upload.

Deployment: scan and backfill --dry-run run on the sandbox before merge, read-only. After deploy, backfill once per listed site (central needs --yes-main-site), then the command gets deleted.

One thing found on the way and left alone: an organizer can't trash their own draft request, because set_request_status() clamps trash out of the requester status set. The trash tests run as a network admin for that reason. Separate ticket.

🤖 Generated with Claude Code

@mcliwanow
mcliwanow force-pushed the budgets/delete-files-with-their-request branch from afdaecf to d148fe2 Compare September 17, 2026 15:32
@mcliwanow
mcliwanow marked this pull request as ready for review September 17, 2026 15:37
@mcliwanow
mcliwanow requested a review from bor0 September 17, 2026 15:37
@mcliwanow mcliwanow self-assigned this Sep 17, 2026
@mcliwanow
mcliwanow requested a review from KokkieH September 17, 2026 15:37
@bor0

bor0 commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Reviewed over four passes, latest at 1acf6b5. Everything I raised is addressed, several of them better than what I suggested. Detailed notes are in the private discussion where this work is tracked.

First pass. Files stored private were invisible to a requester with only the Author role, on the Media Library screens; the WP-CLI command only wrote its copy list on the run that marked a file; the parent lookup used post_status => 'any', which excludes auto-draft and trash; and the marking was one way. Plus the marking path that programmatic inserts missed, a per-item lookup in the admin-ajax redaction, and mark_budget_file() re-entering itself.

Second pass. Asking for private in the query vars takes away Core's own gate for every attachment on the site, and the replacement only lived in the posts_clauses filter; the Attach action read the previous parent out of a stale post cache; and scan dropped a site before its copy list had been recorded.

Third pass. All three closed. The two result paths answer the same question, with a test that drives it through a real posts_pre_query supply. The previous parent is captured on load-upload.php before Core writes the new one, with a test for the uncaptured case, so nothing depends on cache state. scan keeps a site while it still holds a copy nobody has recorded. The update_meta_cache() in get_attachments_from_results() is a good catch on its own: the_posts fires before update_post_caches(), so the marker reads were falling through to a lazy load per file. One thing left, a single-ID attach that the screen-load capture skipped and Core does not.

Fourth pass. That one is fixed, and the capture condition now matches current_action() exactly.

No further findings. Approving.

I have not run the suite at any point, so the tests passing is your claim rather than something I checked.

@mcliwanow

Copy link
Copy Markdown
Contributor Author

Thanks. Two commits with the changes are up. This is ready for another look.

@mcliwanow
mcliwanow requested review from bor0 and removed request for bor0 September 18, 2026 08:09
mcliwanow and others added 10 commits September 18, 2026 14:12
A reimbursement or vendor payment request's attachments are only ever read by
the Files metabox on that request, but `wp_delete_post()` leaves them behind,
reparented to the deleted post's own parent. Delete them with the request
instead, row and file.

This runs on `before_delete_post`, so it covers the Delete Permanently action
along with the scheduled purges of trashed posts and of abandoned auto-drafts.
Trashing still keeps everything, so restoring a request brings its files back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A file uploaded onto a reimbursement or vendor payment request now carries its own
marker and the `private` status from the moment it is created, and files attached
later, through the Files metabox or the Media Library's Attach action, are marked
as they are reparented. The rules that decide who reads a payment file read that
marker first, and still fall back to the parent for files created before this
change.

`read_post` is now decided alongside `edit_post`, because `private` maps it to a
capability every organizer has, and the media details the admin hands to
JavaScript are redacted the same way the XML-RPC ones already were.

A file's classification shouldn't depend on a parent that can be deleted or
changed after the fact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`wp wc-budget-files` gives the marker and the `private` status to files that were
uploaded onto a reimbursement or vendor payment request before `privacy.php` started
applying them, and to files with no parent whose name still identifies them as one.
It writes meta and post status and nothing else, one site per run, with `scan`
reading the network to say which sites need a run and which files are copies of one
still sitting on a request elsewhere. The two new meta keys are the work lists a
later step reads, so they stay in `privacy.php` when the command is deleted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`Database_TestCase` truncates `sitemeta` to build the WordCamp networks, so
`check_upload_mimes()` has no `upload_filetypes` to read and falls back to
images only for the rest of the run. The fixtures that write a real file now
allow PDFs, which production does, and fail loudly if the upload does not
happen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…scan honest

Core unlinks an attachment's file with @Unlink() and reports nothing, so a
deletion run where uploads aren't writable removes the row and leaves the file.
The cascade now logs budget_file_not_deleted when that happens.

The backfill scan checks for a query error after each of its two queries rather
than once after both, and skips deleted and archived sites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The unattached arm was reported whole, marked files included, so a site never
left the scan after a clean run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The status filter now acts on inserts only, so an unrelated attachment saved
while a request's edit screen is open keeps its status. The marker is read by
existence, matching the SQL guard, the upload helper ignores a non-scalar post
parameter, and result priming now includes meta so the marker check doesn't
query per attachment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ackfill's copy list on every run

The Media Library screens append `private` only for `read_private_posts`, which
an Author lacks, so a requester who is a volunteer saw none of their own files.
They ask for the status now, and the SQL guard keeps the gate those screens
skip, so nothing else private comes with it. The parent lookup reads the post
cache instead of querying, so a trashed or unsaved request still counts, and
attaching a file only marks one that had no parent before the attach.

The backfill records a copy on every run its ID is named in, marked or not,
since a copy can be marked before the scan finds its original elsewhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…kup from the admin-ajax redaction

`wp_insert_attachment()` called from PHP sends none of the parameters the upload
routes do, so the marker and the status are now written whenever the inserted
row's own parent is a request. The fixtures that model rows from before any of
this drop the hook while they insert.

The redaction the admin hands to JavaScript reaches the same two cache-only
early-outs the capability filter has, now that both share them, and the status
write happens once per call chain rather than once per hook it triggers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…revious parent before Core writes the new one

The pass over a query's results dropped only payment files, so a set supplied
through `posts_pre_query` came back wider than the same query's clauses would
have allowed. It now drops the same set, and both paths read the capability off
the attachment post type rather than naming it. The Media Library's Attach
action writes `post_parent` with a direct `UPDATE`, so the previous value is
now captured on `load-upload.php` instead of read back out of the post cache,
and a file nobody captured is left alone. `scan` also keeps a site that still
has copies to record, which it dropped once nothing was left to mark.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mcliwanow
mcliwanow force-pushed the budgets/delete-files-with-their-request branch from 61eed48 to 0279e9f Compare September 18, 2026 12:24
`wp_media_attach_action()` casts `media` to an array, so a plain
`?action=attach&media=123` attaches one file. The screen-load capture
skipped that shape and left the file unmarked. Cast the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mcliwanow

Copy link
Copy Markdown
Contributor Author

Thanks. One more commit with the cast is up.

@mcliwanow
mcliwanow requested review from bor0 and removed request for bor0 September 18, 2026 14:34

@bor0 bor0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 1acf6b5. Everything I raised across the four passes is closed; my notes are in the comment above and in the private discussion where this work is tracked.

I have not run the suite, so the tests passing is your claim rather than something I checked.

@mcliwanow
mcliwanow merged commit 48379d5 into production Sep 22, 2026
5 checks passed
@mcliwanow
mcliwanow deleted the budgets/delete-files-with-their-request branch September 22, 2026 10:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants