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)