Add Void action for in-flight submissions#669
Open
jmkritt wants to merge 1 commit into
Open
Conversation
Adds a DocuSign-style void to DocuSeal: a user with destroy permission on a submission can void it as long as it hasn't been fully completed. Voiding requires a reason, halts further signing, notifies every already-invited recipient with the reason, stamps a hollow-outline "VOIDED" watermark on each page of the document, and writes an audit event. Voided is a new terminal status — distinct from archived. ## Schema - submissions.voided_at — nullable timestamp + partial index - New SubmissionEvent.event_type value void_submission. Reason + voided_by_user_id stored in the event's data JSON (mirrors decline_form's reason storage) - SubmissionEvent#set_submission_id and set_account_id changed from unconditional = to ||= so an event can be created with a submission directly (no submitter, since void is account-level) - New WebhookUrl::EVENTS value submission.voided ## Domain - Submission#voided? / #voidable? / #void_event / #void_reason / #voided_by_user - Submission.scope :voided; :active and :pending updated to exclude voided - Submitter#status returns 'voided' (highest priority) when the parent submission is voided. #status_event_at returns voided_at first if set - Submission#has_many_attached :voided_documents — watermarked PDFs ## Service Submissions::Void.call(submission, user:, reason:, request:) validates the reason and voidable? invariant, writes the audit event and timestamp in a transaction, fires submission.voided webhook, notifies every recipient with sent_at set (matches DocuSign — only parties who had received the document are notified; sequential-order submitters who hadn't been reached yet are skipped), and enqueues GenerateVoidedDocumentsJob. Submissions::GenerateVoidedDocuments opens each source PDF with HexaPDF, dedupes by content checksum, and stamps a diagonal red "VOIDED" outline (text rendering mode :stroke, 85% stroke alpha, size 150, line width 2.2) plus a footer bar with date / voider / reason on every page. Uses submission.with_lock + a count-based short-circuit so a Sidekiq retry can't produce duplicate attachments. ## Routing - POST /api/submissions/:submission_id/void — JSON - POST /submissions/:submission_id/void — admin form - GET /submissions/:submission_id/void/new — modal reason form start_form_controller, submit_form_controller, and submit_form_decline_controller reject voided submissions on every signing path so a stale email link can't sneak through. SubmissionsUnarchiveController refuses to unarchive a voided submission. ## UI - Submission detail page: red banner with reason / voider / timestamp + download list for the watermarked PDFs. Red Void button in the header opens a turbo-modal that requires a reason. Send- email / send-SMS / sign-in-person / copy-share-link / unarchive controls hidden when voided - Submission list partial: red VOIDED badge takes priority over expired/completed; per-submitter actions hidden when voided - New submit_form/voided.html.erb — branded landing page rendered to anyone clicking a stale signing link - New submitter_mailer#voided_email and template — surfaces reason ## API Submissions::SerializeForApi reports status: 'voided' and includes void_reason plus voided document URLs. voided_at is serialized alongside archived_at. Submissions::Filter accepts ?status=voided. ## Not included - RSpec specs - Locale parity beyond English - Account-level toggle for the watermark - Distinct :void CanCanCan ability (uses :destroy)
hhaya6920-collab
approved these changes
May 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a DocuSign-style void action: a user with destroy permission on a submission can void it as long as it isn't fully completed. Voiding requires a reason, blocks further signing, notifies every already-invited recipient with the reason, stamps a hollow-outline
VOIDEDwatermark on each page of the PDF, and writes an audit event. Voided is a new terminal status — distinct from archived (which is housekeeping).Closes a real gap: users migrating from DocuSign / HelloSign / Adobe Sign reach for this immediately when they need to invalidate a contract with a recorded reason (counter-offers replacing prior offers, agreements pulled for legal review, mistakes sent to wrong recipients). Archive is a workaround that loses the audit story.
Behavior
void_submission)VOIDEDwatermark + footersubmission.archivedsubmission.voidedEndpoints
POST /api/submissions/:submission_id/void(JSON, body:{reason: ...})POST /submissions/:submission_id/void(admin form)GET /submissions/:submission_id/void/new(modal reason form)Schema
One nullable column + partial index; reason and voider live in
SubmissionEvent.data(mirrorsdecline_form). Migration is purely additive.Test plan
sent_at-set submitters notified; later-order recipients skipped (matches DocuSign)submitter.completed_atretained; events ordered chronologicallytext_rendering_mode: :stroke); idempotent on retry; dedupes identical source PDFs by checksumvoided.html.erb;POST /s/:slugreturns 422Not included
:voidCanCanCan ability (uses:destroy)Design choices
sent_atset are emailed (matches DocuSign — sequential-order recipients who haven't been reached aren't surprised by a notice for a document they never knew existed):destroyability reused rather than introducing:void— voiding is the "delete this in-flight" operation