Skip to content

feat: parcel return logic (GH-69)#232

Open
Jakubk15 wants to merge 18 commits into
masterfrom
feat/parcel-return-gh-69
Open

feat: parcel return logic (GH-69)#232
Jakubk15 wants to merge 18 commits into
masterfrom
feat/parcel-return-gh-69

Conversation

@Jakubk15

@Jakubk15 Jakubk15 commented Jul 3, 2026

Copy link
Copy Markdown
Member

Closes #69.

Players can return collected parcels by depositing the same items back at any locker. The returned parcel ships back to its original entry locker like a normal parcel.

How it works

  • Collecting a parcel now marks it COLLECTED (new status) instead of deleting it, and keeps its content row as the collection snapshot used to validate a later return.
  • The locker GUI gains a Return button → list of returnable parcels (with remaining return-window time in the lore) → per-size deposit GUI where the player puts the items back and confirms.
  • Deposited items are validated against the snapshot with configurable strictness: checkDurability, checkItemName, checkLore, checkEnchantments, checkNbt (all true by default = items must be identical; relaxing flags loosens individual attributes).
  • A per-size return fee (5.0 / 12.5 / 25.0 by default) is charged, bypassed by parcellockers.fee.bypass.
  • On success the parcel is reversed (sender ↔ receiver, entry ↔ destination lockers), flipped back to SENT, and delivered with the normal priority-aware delay.
  • Returns are time-limited (parcel-return-window, default 7 days). A background task purges expired collected parcels (parcel + content + collected row) every 30 minutes.

Correctness notes

  • All status flips are conditional UPDATEs (markCollected only from DELIVERED, markReturned only from COLLECTED), so double-collect, double-return, return-vs-purge and return-vs-admin-delete races resolve safely; covered by integration tests.
  • ParcelSendTask now aborts unless the parcel is still SENT, preventing re-delivery duplication across restarts.
  • The post-return "commit point" in ParcelReturnService is structurally isolated from the refund/give-back recovery path (including synchronous throws), so a committed return can never be undone into duplicated items.
  • The admin status toggle refuses to change a COLLECTED parcel's status (it would otherwise re-arm delivery of already-handed-out items).
  • Return-window expiry is compared temporally in Java rather than via SQL string comparison (InstantPersister stores ISO-8601 strings, which don't order correctly lexicographically).

Config additions

parcel-return-window, small/medium/large-parcel-return-fee, return-checks.* (5 flags), new GUI items/titles for the return list and deposit GUIs, and new notices (returned, cannotReturn, returnItemsMismatch, returnWindowExpired, returnFeeWithdrawn, admin.statusLocked).

Testing

  • 40 unit tests across 8 classes, all green locally (equivalence, validator, return-window logic, send-task guard, admin guard, and existing suites).
  • ⚠️ 3 Testcontainers integration test classes (collected-parcel repository, conditional collect/return flips — the race guards) have not run yet: locally blocked by a machine-specific Docker port-relay conflict (wslrelay.exe vs com.docker.backend loopback binding), and the CI workflow currently runs only shadowJar, not ./gradlew test. They should be run once (locally after a wsl --shutdown, or by adding a test step to CI) before merge.
  • runServer smoke test passed: plugin enables cleanly, collected_parcels table created, no errors.

Notes

  • Branch is based on fix/issue-222 (fix/issue 222 #230, merged) because this feature reworks the same collect flow.
  • Went through subagent-driven development with per-task review plus a final whole-branch review; both review blockers (admin-toggle resurrection, post-commit exception routing) are fixed in the last two commits. A small batch of follow-up cleanups (cosmetic notices, stale-row cleanup in purge, a narrow stale-admin-GUI race) will be filed as a separate issue.

🤖 Generated with Claude Code

Jakubk15 and others added 12 commits July 3, 2026 14:01
The collected_at column is a string-persisted ISO-8601 Instant; SQL range
operators compare it lexicographically and misorder same-second values.
Filter in Java and cover the cutoff boundary in the integration test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu
…y to ParcelRepository

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu
…es to config

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu
Orchestrates the return of a COLLECTED parcel: re-verifies status/receiver,
checks the return window, validates deposited items against the stored
content snapshot, checks entry-locker capacity, charges the return fee
(bypassable), flips the parcel into a reverse SENT shipment via
ParcelService.markReturned, and schedules the normal delivery task. Every
abort path hands the deposited items back and sends a specific notice; the
fee is refunded on any failure after it was charged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu
After markReturned succeeds the parcel is a live shipment; a throwing
DeliveryManager.create (stale cached delivery from the original trip)
must not trigger the refund/give-back recovery, which would duplicate
the deposited items. Schedule the send task first and upsert the
delivery timestamp instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu
Wires the last piece of the parcel return flow into the GUI layer:
GuiManager gains getReturnableParcels/getCollectedInfo/returnParcel
passthroughs to ParcelService/ParcelReturnService, ReturnGui lists a
player's collected parcels with a remaining-return-window lore line,
ReturnDepositGui lets them deposit the original items and confirms
the return (or gives everything back on close), and LockerGui gets a
"Return parcels" button between collect and send. Threads the return
window Duration through SendingGui/ItemStorageGui as well, since they
rebuild LockerGui/each other and would otherwise fail to compile
after LockerGui's constructor changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a comprehensive parcel return system, including return validation, configurable item equivalence checks, database persistence for collected parcels, a periodic purge task for expired returns, and dedicated return GUIs. The code review highlights critical thread-safety issues with asynchronous Vault economy calls, potential NullPointerExceptions due to missing null/air checks in item validation, a potential ConcurrentModificationException when modifying item enchantments, and a performance concern regarding full table scans for expired parcels.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java Outdated
Jakubk15 and others added 2 commits July 4, 2026 10:03
Flipping a COLLECTED parcel's status back to SENT re-armed a delivery
even though the content row still held the collection snapshot,
letting an admin one-click the receiver into collecting the same
items twice. AdminParcelService.changeStatus now rejects any status
change while a parcel is COLLECTED, and AdminParcelEditGui no longer
offers the toggle for one, so the click surfaces the new
admin.statusLocked notice instead of a silent no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu
The post-commit block after markReturned succeeded relied on each
async step's own .exceptionally for safety, but a synchronous throw
inside it (e.g. the scheduler rejecting work during plugin disable)
would propagate to the outer exceptionally, which refunds the fee and
gives the deposited items back even though the parcel is already a
live SENT shipment whose content row holds those same items —
duplicating them. Wrap the post-commit statements in a try/catch that
only logs, so no synchronous exception past the commit point can ever
reach the refund/give-back recovery path. Pre-commit behavior
(refund + give-back + cannotReturn on a failed markReturned) is
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu
@Jakubk15 Jakubk15 changed the title Add parcel return feature feat: parcel return logic (GH-69) Jul 4, 2026
@Jakubk15 Jakubk15 marked this pull request as ready for review July 4, 2026 13:01
Jakubk15 and others added 3 commits July 4, 2026 15:11
Correctness hardening:
- Make AdminParcelService.changeStatus a conditional UPDATE (ParcelRepository.updateIfStatus,
  WHERE status = expected) so a stale admin-GUI snapshot can no longer resurrect a parcel that
  concurrently became COLLECTED.
- Upsert (not insert-if-absent) the collected_parcels row on collect, so a re-collect after a
  failed best-effort delete overwrites the stale collectedAt instead of shortening the new
  return window.

Cleanup / UX:
- ReturnWindowPurgeTask now also deletes the delivery row for a purged parcel, closing the leak
  left by a failed delete at ParcelSendTask.deliver time.
- Move the returnFeeWithdrawn notice to after markReturned commits, so a lost race (fee
  refunded) no longer leaves the player thinking the fee was kept.
- Reword insufficientFunds to be fee-neutral; it fires for both send and return fees.
- Drop the dead result == null check in ReturnGui.show (getReturnableParcels never completes
  with null).

Tests / docs / plumbing:
- Add name/lore relaxed-flag coverage to ReturnItemEquivalenceTest.
- Document the transitive-equivalence assumption ParcelReturnValidator's grouping relies on.
- Switch new-code exception logging to LOGGER.log(Level, msg, throwable) so stack traces aren't
  dropped.
- Move returnWindow onto GuiManager (alongside the existing allowCollectingFromAnyLocker
  pattern) instead of threading it through LockerGui/SendingGui/ItemStorageGui as a pass-through
  constructor param.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…vice

withdrawPlayer/depositPlayer were called from inside async CompletableFuture
continuations chained off repository lookups, so they executed on a DB-executor
thread rather than the main thread Vault providers expect. Dispatch both through
Scheduler.complete/run to hop back onto the primary thread before touching Economy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Jakubk15

Jakubk15 commented Jul 4, 2026

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown

Claude finished @Jakubk15's task in 2m 21s —— View job


Claude's Review

  • Gather context (PR description, diff, prior Gemini review comments)
  • Check whether prior Gemini review findings were already addressed by recent commits
  • Review new return-flow code for correctness/thread-safety/race issues
  • Review GUI, config, and repository/purge-task changes
  • Post final review

Prior Gemini findings — status

Finding Status
withdrawPlayer on async thread (ParcelReturnService) ✅ Fixed in 0131680 — now wrapped in this.scheduler.complete(...)
depositPlayer on async thread (ParcelReturnService.refund, line 260) ✅ Fixed in 0131680 — now wrapped in this.scheduler.run(...)
Full table scan in CollectedParcelRepositoryOrmLite.findExpired Not changed, but this is a deliberate, documented tradeoff (line 41-44 comment) — the table only ever holds parcels inside the return window, and the purge runs every 30 min, so the scan cost is bounded and negligible. Not a blocker.
Missing null checks in ReturnItemEquivalence.test / ParcelReturnValidator.matches Not changed. Traced the only call site (ReturnDepositGui.takeDepositedItems, line 99-106) — it already filters null/isEmpty() items before they ever reach the validator, so this isn't currently reachable. Still a reasonable defensive hardening for a public API that could gain other callers later — low priority, non-blocking.
Possible ConcurrentModificationException in ReturnItemEquivalence.normalize (enchant removal) Likely a false positive: ItemMeta#getEnchants() returns a defensive copy (ImmutableMap.copyOf(...)) in the CraftBukkit/Paper implementation, so mutating meta via removeEnchant while iterating the returned keySet() doesn't touch the same backing map. The new ArrayList<>(...) wrap costs nothing and is a cheap hedge, but not required for correctness with current Bukkit APIs.

Own review of the return flow

Went through ParcelReturnService, ParcelServiceImpl.collect, ParcelRepositoryOrmLite (conditional markCollected/markReturned/updateIfStatus), AdminParcelService.changeStatus, ParcelSendTask, and ReturnWindowPurgeTask. This is careful, well-reasoned code:

  • All state transitions are conditional UPDATEs guarded by expected status (markCollected only from DELIVERED, markReturned only from COLLECTED, updateIfStatus for the admin-edit guard), so the double-collect/double-return/return-vs-purge/return-vs-admin-edit races are closed at the DB layer, not just in-memory.
  • The post-commit section of ParcelReturnService.proceedWithReturn (ParcelReturnService.java:186-236) is correctly isolated from the refund/give-back path with a try/catch that only logs — a nice bit of defense against a partially-applied return duplicating items.
  • ParcelSendTask.decide aborting when the parcel isn't SENT correctly prevents re-delivery after a restart.
  • AdminParcelService.changeStatus refusing to touch COLLECTED parcels, combined with updateIfStatus for the TOCTOU between GUI-open and edit-submit, closes the "resurrect a collected parcel" issue called out in the PR description.
  • countParcelsByDestinationLocker correctly excludes COLLECTED parcels from locker occupancy (items have already left).
  • ReturnWindowPurgeTask deletes the parcel/content first and only removes the collected_parcels row after that succeeds, so a partial failure is safely retried on the next run rather than leaking or double-deleting.

No new correctness issues found beyond what's listed above — nothing here blocks merging on my read. The remaining integration tests (blocked locally by the Docker port-relay conflict mentioned in the PR description) are the main outstanding item before merge, as called out by the author.

@EternalCodeTeam EternalCodeTeam deleted a comment from claude Bot Jul 4, 2026
@EternalCodeTeam EternalCodeTeam deleted a comment from claude Bot Jul 4, 2026
@Jakubk15 Jakubk15 added the ✅ needs testing Issue needs verification. label Jul 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✅ needs testing Issue needs verification.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parcel return logic

1 participant