Skip to content

Add Void action for in-flight submissions#669

Open
jmkritt wants to merge 1 commit into
docusealco:masterfrom
jmkritt:upstream-pr/void-submission
Open

Add Void action for in-flight submissions#669
jmkritt wants to merge 1 commit into
docusealco:masterfrom
jmkritt:upstream-pr/void-submission

Conversation

@jmkritt

@jmkritt jmkritt commented May 8, 2026

Copy link
Copy Markdown

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 VOIDED watermark 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

Archive (existing) Void (this PR)
Reason captured No Required
Signing blocked No Yes
Recipients notified No Yes (already-invited)
Audit event No Yes (void_submission)
Visual on document No Hollow red VOIDED watermark + footer
Reversible Yes No (terminal)
Webhook submission.archived submission.voided

Endpoints

  • 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 (mirrors decline_form). Migration is purely additive.

Test plan

  • Sequential signing: only sent_at-set submitters notified; later-order recipients skipped (matches DocuSign)
  • Partial-signature preservation: submitter.completed_at retained; events ordered chronologically
  • Watermark renders as a translucent red outline (HexaPDF text_rendering_mode: :stroke); idempotent on retry; dedupes identical source PDFs by checksum
  • Voided submission's signing link renders the branded voided.html.erb; POST /s/:slug returns 422

Not included

  • RSpec specs
  • Locale parity beyond English
  • Account-level toggle for the watermark
  • Distinct :void CanCanCan ability (uses :destroy)

Design choices

  • Watermark style: diagonal red outline at 85% stroke alpha, no fill — leaves underlying text fully readable
  • Notification gating: only submitters with sent_at set 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)
  • :destroy ability reused rather than introducing :void — voiding is the "delete this in-flight" operation

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)
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