Add Void action for in-flight submissions

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)
pull/669/head
Jeremy Kritt 1 month ago
parent be27ce4161
commit 14c844813d

@ -0,0 +1,24 @@
# frozen_string_literal: true
module Api
class SubmissionsVoidController < ApiBaseController
load_and_authorize_resource :submission
before_action only: :create do
authorize!(:destroy, @submission)
end
def create
Submissions::Void.call(@submission, user: current_user, reason: params[:reason], request:)
render json: {
id: @submission.id,
status: 'voided',
voided_at: @submission.voided_at,
void_reason: @submission.void_reason
}
rescue Submissions::Void::ReasonRequiredError, Submissions::Void::NotVoidableError => e
render json: { error: e.message }, status: :unprocessable_content
end
end
end

@ -128,7 +128,8 @@ class StartFormController < ApplicationController
submitter ||=
Submitter
.where(submission: template.submissions.where(expire_at: Time.current..)
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
.or(template.submissions.where(expire_at: nil))
.where(archived_at: nil, voided_at: nil))
.order(id: :desc)
.where(declined_at: nil)
.where(external_id: nil)

@ -4,6 +4,11 @@ class SubmissionsUnarchiveController < ApplicationController
load_and_authorize_resource :submission
def create
if @submission.voided_at?
return redirect_to submission_path(@submission),
alert: I18n.t('voided_submission_cannot_be_unarchived')
end
@submission.update!(archived_at: nil)
redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived')

@ -0,0 +1,21 @@
# frozen_string_literal: true
class SubmissionsVoidController < ApplicationController
load_and_authorize_resource :submission
before_action only: %i[new create] do
authorize!(:destroy, @submission)
end
def new
render layout: false
end
def create
Submissions::Void.call(@submission, user: current_user, reason: params[:reason], request:)
redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_voided')
rescue Submissions::Void::ReasonRequiredError, Submissions::Void::NotVoidableError => e
redirect_to submission_path(@submission), alert: e.message
end
end

@ -58,6 +58,10 @@ class SubmitFormController < ApplicationController
return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_content
end
if @submitter.submission.voided_at?
return render json: { error: I18n.t('form_has_been_voided') }, status: :unprocessable_content
end
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at?
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
@ -109,6 +113,7 @@ class SubmitFormController < ApplicationController
end
def maybe_render_locked_page
return render :voided if @submitter.submission.voided_at?
return render :archived if @submitter.submission.template&.archived_at? ||
@submitter.submission.archived_at? ||
@submitter.account.archived_at?

@ -9,6 +9,7 @@ class SubmitFormDeclineController < ApplicationController
def create
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.voided_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||

@ -0,0 +1,15 @@
# frozen_string_literal: true
class GenerateVoidedDocumentsJob
include Sidekiq::Job
sidekiq_options queue: :default
def perform(params = {})
submission = Submission.find_by(id: params['submission_id'])
return unless submission&.voided_at?
Submissions::GenerateVoidedDocuments.call(submission)
end
end

@ -0,0 +1,43 @@
# frozen_string_literal: true
class SendSubmissionVoidedWebhookRequestJob
include Sidekiq::Job
sidekiq_options queue: :webhooks
MAX_ATTEMPTS = 10
def perform(params = {})
submission = Submission.find_by(id: params['submission_id'])
return unless submission
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
attempt = params['attempt'].to_i
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.voided')
payload = submission.as_json(only: %i[id voided_at]).merge(
'reason' => submission.void_reason,
'voided_by_user_id' => submission.void_event&.data&.dig('voided_by_user_id')
)
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.voided',
event_uuid: params['event_uuid'],
record: submission,
attempt:,
data: payload)
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionVoidedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
end

@ -102,6 +102,28 @@ class SubmitterMailer < ApplicationMailer
end
end
def voided_email(submitter, user)
@current_account = submitter.submission.account
@submitter = submitter
@submission = submitter.submission
@user = user
@reason = @submission.void_reason
reply_to = build_submitter_reply_to(@submitter)
assign_message_metadata('submitter_voided', @submitter)
maybe_set_custom_domain(@submitter)
I18n.with_locale(@current_account.locale) do
mail(from: from_address_for_submitter(submitter),
to: @submitter.friendly_name,
reply_to:,
subject: I18n.t(:name_voided_by_sender,
name: (@submission.name || @submission.template.name).truncate(40)))
end
end
def documents_copy_email(submitter, to: nil, sig: false)
@current_account = submitter.submission.account
@submitter = submitter

@ -17,6 +17,7 @@
# template_submitters :text
# variables :text
# variables_schema :text
# voided_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
@ -28,6 +29,7 @@
# index_submissions_on_account_id_and_id (account_id,id)
# index_submissions_on_account_id_and_template_id_and_id (account_id,template_id,id) WHERE (archived_at IS NULL)
# index_submissions_on_account_id_and_template_id_and_id_archived (account_id,template_id,id) WHERE (archived_at IS NOT NULL)
# index_submissions_on_account_id_and_template_id_and_id_voided (account_id,template_id,id) WHERE (voided_at IS NOT NULL)
# index_submissions_on_created_by_user_id (created_by_user_id)
# index_submissions_on_slug (slug) UNIQUE
# index_submissions_on_template_id (template_id)
@ -68,6 +70,7 @@ class Submission < ApplicationRecord
has_many_attached :preview_documents
has_many_attached :documents
has_many_attached :voided_documents
has_many :template_accesses, primary_key: :template_id, foreign_key: :template_id, dependent: nil, inverse_of: false
@ -86,10 +89,12 @@ class Submission < ApplicationRecord
has_many :template_schema_dynamic_document_attachments,
through: :template_schema_dynamic_document_versions, source: :document_attachment
scope :active, -> { where(archived_at: nil) }
scope :active, -> { where(archived_at: nil, voided_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
scope :voided, -> { where.not(voided_at: nil) }
scope :pending, lambda {
where(expire_at: nil).or(where(expire_at: Time.current..))
where(voided_at: nil)
.where(expire_at: nil).or(where(voided_at: nil, expire_at: Time.current..))
.where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
}
@ -125,6 +130,29 @@ class Submission < ApplicationRecord
expire_at && expire_at <= Time.current
end
def voided?
voided_at.present?
end
def voidable?
!voided? && !submitters.all?(&:completed_at?)
end
def void_event
submission_events.find_by(event_type: :void_submission)
end
def void_reason
void_event&.data&.dig('reason')
end
def voided_by_user
return unless voided?
user_id = void_event&.data&.dig('voided_by_user_id')
user_id && User.find_by(id: user_id)
end
def schema_documents
return documents_attachments unless template_id?

@ -65,16 +65,17 @@ class SubmissionEvent < ApplicationRecord
complete_form: 'complete_form',
decline_form: 'decline_form',
delegate_form: 'delegate_form',
api_complete_form: 'api_complete_form'
api_complete_form: 'api_complete_form',
void_submission: 'void_submission'
}, scope: false
private
def set_submission_id
self.submission_id = submitter&.submission_id
self.submission_id ||= submitter&.submission_id
end
def set_account_id
self.account_id = submitter&.account_id
self.account_id ||= submitter&.account_id || submission&.account_id
end
end

@ -71,7 +71,9 @@ class Submitter < ApplicationRecord
after_destroy :anonymize_email_events, if: -> { Docuseal.multitenant? }
def status
if declined_at?
if submission.voided_at?
'voided'
elsif declined_at?
'declined'
elsif completed_at?
'completed'
@ -105,7 +107,7 @@ class Submitter < ApplicationRecord
end
def status_event_at
declined_at || completed_at || opened_at || sent_at || created_at
submission.voided_at || declined_at || completed_at || opened_at || sent_at || created_at
end
def with_signature_fields?

@ -32,6 +32,7 @@ class WebhookUrl < ApplicationRecord
submission.completed
submission.expired
submission.archived
submission.voided
template.created
template.updated
template.archived

@ -9,6 +9,44 @@
<% with_timestamp_seconds = configs.find { |e| e.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true %>
<% with_signature_id_reason = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false %>
<main style="max-width: 1600px" class="mx-auto pl-4">
<% if @submission.voided_at? %>
<div class="mr-4 mt-2 mb-2 rounded-xl border border-error bg-error/10 px-4 py-3">
<div class="flex items-start space-x-3">
<%= svg_icon('x_circle', class: 'w-6 h-6 text-error flex-shrink-0 mt-0.5') %>
<div class="flex-1">
<div class="font-semibold text-error uppercase tracking-wide text-sm">
<%= t('voided') %>
</div>
<div class="text-sm mt-0.5">
<%= t('form_has_been_voided_on_html', time: l(@submission.voided_at.in_time_zone(@submission.account.timezone), format: :long, locale: @submission.account.locale)).html_safe %>
<% if (voided_by = @submission.voided_by_user) %>
&mdash; <%= t('voided_by_name', name: voided_by.full_name.presence || voided_by.email) %>
<% end %>
</div>
<% if (reason = @submission.void_reason).present? %>
<div class="text-sm mt-1">
<span class="font-semibold"><%= t('reason') %>:</span>
<span class="whitespace-pre-wrap"><%= reason %></span>
</div>
<% end %>
<% if @submission.voided_documents.attached? %>
<div class="mt-2 flex flex-wrap gap-2">
<% @submission.voided_documents.attachments.each do |att| %>
<a href="<%= ActiveStorage::Blob.proxy_path(att.blob, expires_at: 4.hours.from_now) %>" class="inline-flex items-center gap-1 text-sm font-medium underline" target="_blank">
<%= svg_icon('download', class: 'w-4 h-4') %>
<%= att.filename.base %>
</a>
<% end %>
</div>
<% else %>
<div class="mt-2 text-xs opacity-70">
<%= t('voided_watermark_pending') %>
</div>
<% end %>
</div>
</div>
</div>
<% end %>
<div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100">
<a href="<%= signed_in? && @submission.account_id == current_account&.id && @submission.template ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1">
<span><%= render 'submissions/logo' %></span>
@ -16,9 +54,15 @@
</a>
<div class="space-x-3 flex items-center">
<% is_all_completed = @submission.submitters.to_a.all?(&:completed_at?) %>
<% if signed_in? && can?(:create, @submission) && @submission.archived_at? && !is_all_completed %>
<% if signed_in? && can?(:create, @submission) && @submission.archived_at? && !@submission.voided_at? && !is_all_completed %>
<%= button_to button_title(title: t('unarchive'), disabled_with: t('unarchive')[0..-2], icon: svg_icon('rotate', class: 'w-6 h-6')), submission_unarchive_index_path(@submission), class: 'btn btn-primary btn-ghost text-base hidden md:flex' %>
<% end %>
<% if signed_in? && can?(:destroy, @submission) && @submission.voidable? %>
<%= link_to new_submission_void_path(@submission), class: 'white-button !text-error', data: { turbo_frame: :modal } do %>
<%= svg_icon('x_circle', class: 'w-6 h-6') %>
<span class="hidden md:inline"><%= t('void') %></span>
<% end %>
<% end %>
<% if @submission.audit_trail.present? %>
<a href="<%= ActiveStorage::Blob.proxy_path(@submission.audit_trail.blob, expires_at: 4.hours.from_now) %>" class="white-button" target="_blank">
<%= svg_icon('external_link', class: 'w-6 h-6') %>
@ -68,7 +112,7 @@
<% end %>
</div>
<% end %>
<% elsif @submission.submitters.to_a.size == 1 && !@submission.expired? && !@submission.submitters.to_a.first.declined_at? && !@submission.archived_at? && !@submission.template&.archived_at? %>
<% elsif @submission.submitters.to_a.size == 1 && !@submission.expired? && !@submission.submitters.to_a.first.declined_at? && !@submission.archived_at? && !@submission.voided_at? && !@submission.template&.archived_at? %>
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: @submission.submitters.to_a.first.slug, host: form_link_host), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy_share_link'), copied_title: t('copied_to_clipboard') %>
<% end %>
</div>
@ -159,7 +203,7 @@
<%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %>
</span>
</div>
<% if signed_in? && can?(:update, @submission) && submitter && !submitter.completed_at? && !submitter.declined_at? && !@submission.archived_at? && !@submission.template&.archived_at? && !@submission.expired? && !submitter.start_form_submission_events.any? %>
<% if signed_in? && can?(:update, @submission) && submitter && !submitter.completed_at? && !submitter.declined_at? && !@submission.archived_at? && !@submission.voided_at? && !@submission.template&.archived_at? && !@submission.expired? && !submitter.start_form_submission_events.any? %>
<span class="tooltip tooltip-left" data-tip="<%= t('edit') %>">
<%= link_to edit_submitter_path(submitter), class: 'shrink-0 inline md:hidden md:group-hover:inline', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('pencil', class: 'w-5 h-5') %>
@ -198,7 +242,9 @@
<%= svg_icon('writing', class: 'w-5 h-5') %>
<% end %>
<span>
<% if submitter&.declined_at? %>
<% if @submission.voided_at? %>
<%= t('voided_on_time', time: l(@submission.voided_at.in_time_zone(@submission.account.timezone), format: :short, locale: @submission.account.locale)) %>
<% elsif submitter&.declined_at? %>
<%= t('declined_on_time', time: l(submitter.declined_at.in_time_zone(@submission.account.timezone), format: :short, locale: @submission.account.locale)) %>
<% elsif submitter %>
<% if submitter.completed_at? %>
@ -225,15 +271,15 @@
</span>
</div>
<% end %>
<% if signed_in? && submitter && submitter.email && !submitter.completed_at && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:update, @submission) && Accounts.can_send_emails?(current_account) && !@submission.expired? && !submitter.declined_at? %>
<% if signed_in? && submitter && submitter.email && !submitter.completed_at && !@submission.archived_at? && !@submission.voided_at? && !@submission.template&.archived_at? && can?(:update, @submission) && Accounts.can_send_emails?(current_account) && !@submission.expired? && !submitter.declined_at? %>
<div class="mt-2 mb-1">
<%= button_to button_title(title: submitter.sent_at? ? t('re_send_email') : t('send_email'), disabled_with: t('sending')), submitter_send_email_index_path(submitter), class: 'btn btn-sm btn-primary w-full' %>
</div>
<% end %>
<% if signed_in? && submitter && submitter.phone && !submitter.completed_at && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:update, @submission) && !@submission.expired? && !submitter.declined_at? %>
<% if signed_in? && submitter && submitter.phone && !submitter.completed_at && !@submission.archived_at? && !@submission.voided_at? && !@submission.template&.archived_at? && can?(:update, @submission) && !@submission.expired? && !submitter.declined_at? %>
<%= render 'submissions/send_sms_button', submitter: %>
<% end %>
<% if signed_in? && submitter && !submitter.completed_at? && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:create, @submission) && !@submission.expired? && !submitter.declined_at? %>
<% if signed_in? && submitter && !submitter.completed_at? && !@submission.archived_at? && !@submission.voided_at? && !@submission.template&.archived_at? && can?(:create, @submission) && !@submission.expired? && !submitter.declined_at? %>
<div class="mt-2 mb-1">
<a class="btn btn-sm btn-primary w-full" target="_blank" href="<%= submit_form_path(slug: submitter.slug) %>">
<%= t('sign_in_person') %>

@ -0,0 +1,17 @@
<%= render 'shared/turbo_modal', title: t('void_submission') do %>
<div class="px-1 pt-2 pb-1">
<p class="text-sm mb-4 text-base-content/80">
<%= t('void_submission_confirmation') %>
</p>
<%= form_with url: submission_void_index_path(@submission), method: :post, data: { turbo: false } do |f| %>
<div class="form-control">
<label class="label" for="reason"><%= t('enter_reason_for_voiding') %></label>
<%= f.text_area :reason, required: true, class: 'base-input w-full py-2', dir: 'auto', placeholder: t('provide_a_reason'), rows: 5 %>
</div>
<div class="flex justify-end space-x-2 mt-4">
<button type="button" class="btn btn-ghost" data-action="click:turbo-modal#close"><%= t('cancel') %></button>
<%= f.button button_title(title: t('void'), disabled_with: t('void')), class: 'btn btn-error' %>
</div>
<% end %>
</div>
<% end %>

@ -0,0 +1,28 @@
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="flex items-center justify-center">
<%= render 'start_form/banner' %>
</div>
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center">
<div class="mr-3">
<%= svg_icon('x_circle', class: 'w-10 h-10') %>
</div>
<div dir="auto">
<p class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template.name %></p>
<p class="text-sm"><%= t('form_has_been_voided_on_html', time: l(@submitter.submission.voided_at, format: :long)) %></p>
<% if (voided_by = @submitter.submission.voided_by_user) %>
<p class="text-xs mt-1 opacity-70"><%= t('voided_by_name', name: voided_by.full_name.presence || voided_by.email) %></p>
<% end %>
<% if (reason = @submitter.submission.void_reason).present? %>
<p class="text-sm mt-2"><%= t('reason') %>:</p>
<p class="text-sm whitespace-pre-wrap"><%= reason %></p>
<% end %>
</div>
</div>
</div>
</div>
</div>
</div>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>

@ -0,0 +1,7 @@
<p><%= t('hi_there') %>,</p>
<p><%= t('name_has_been_voided_with_reason_html', name: @submission.name || @submission.template.name, sender: @user&.full_name.presence || @user&.email || @current_account.name) %></p>
<% if @reason.present? %>
<p><strong><%= t('reason') %>:</strong></p>
<%= simple_format(h(@reason)) %>
<% end %>
<p><%= t('voided_email_no_action_required') %></p>

@ -1,4 +1,4 @@
<% status_badges = { 'awaiting' => 'badge-info', 'sent' => 'badge-info', 'completed' => 'badge-success', 'opened' => 'badge-warning', 'declined' => 'badge-error' } %>
<% status_badges = { 'awaiting' => 'badge-info', 'sent' => 'badge-info', 'completed' => 'badge-success', 'opened' => 'badge-warning', 'declined' => 'badge-error', 'voided' => 'badge-error' } %>
<div class="bg-base-200 rounded-2xl flex flex-col sm:flex-row items-strech">
<% if local_assigns[:with_template] %>
<% template = submission.template %>
@ -34,7 +34,13 @@
<% submitter = submitters.first %>
<div class="flex items-center space-x-4">
<span class="flex flex-col md:flex-row md:items-center gap-3">
<% if submission.expired? && !submitter.completed_at? && !submitter.declined_at? %>
<% if submission.voided_at? %>
<a href="<%= submission_path(submission) %>" class="flex z-[1]">
<span class="badge badge-error md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold tooltip" data-tip="<%= l(submission.voided_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<%= t('voided') %>
</span>
</a>
<% elsif submission.expired? && !submitter.completed_at? && !submitter.declined_at? %>
<div class="tooltip flex" data-tip="<%= l(submission.expire_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge badge-error md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold">
<%= t('expired') %>
@ -51,7 +57,7 @@
<a href="<%= submission_path(submission) %>" class="text-lg break-all peer">
<%= submitter.name || submitter.email || submitter.phone %>
</a>
<% if can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %>
<% if can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.voided_at? && !submission.expired? && !submitter.declined_at? %>
<span class="pl-0.5 tooltip tooltip-top md:opacity-0 md:hover:opacity-100 md:peer-hover:opacity-100" data-tip="<%= t('edit') %>">
<%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('pencil', class: 'w-5 h-5') %>
@ -78,7 +84,7 @@
</download-button>
</div>
</div>
<% elsif !submission.archived_at? && !template&.archived_at? && !submission.expired? && !submitter.declined_at? %>
<% elsif !submission.archived_at? && !submission.voided_at? && !template&.archived_at? && !submission.expired? && !submitter.declined_at? %>
<% if current_user.email == submitter.email %>
<div class="flex-1 md:flex-none md:w-36 flex">
<a href="<%= submit_form_path(slug: submitter.slug) %>" data-turbo="false" target="_blank" id="sign_yourself_button" class="btn btn-sm btn-neutral btn-outline bg-white w-full md:w-36 flex z-[1]">
@ -101,7 +107,7 @@
<%= t('view') %>
</a>
</div>
<% if !submission.archived_at? && !template&.archived_at? && can?(:destroy, submission) %>
<% if !submission.archived_at? && !submission.voided_at? && !template&.archived_at? && can?(:destroy, submission) %>
<span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: nil, icon: svg_icon('archive', class: 'w-6 h-6'), icon_disabled: svg_icon('loader', class: 'w-6 h-6 animate-spin')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>
</span>
@ -115,7 +121,13 @@
<% else %>
<div class="space-y-1 w-full md:mr-2">
<div class="flex flex-col md:flex-row md:items-center gap-3">
<% if is_submission_completed %>
<% if submission.voided_at? %>
<a href="<%= submission_path(submission) %>" class="z-[1] tooltip flex" data-tip="<%= l(submission.voided_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge badge-error md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold">
<%= t('voided') %>
</span>
</a>
<% elsif is_submission_completed %>
<% latest_submitter = submitters.select(&:completed_at?).max_by(&:completed_at) %>
<a href="<%= submission_path(submission) %>" class="z-[1] tooltip flex" data-tip="<%= l(latest_submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge <%= status_badges[latest_submitter.status] %> md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold">
@ -133,7 +145,7 @@
<% submitters.each_with_index do |submitter, index| %>
<div class="relative flex justify-between items-start md:items-center space-x-3">
<span class="flex flex-col md:flex-row md:items-center gap-2">
<% if !is_submission_completed && !submission.expired? %>
<% if !is_submission_completed && !submission.expired? && !submission.voided_at? %>
<a href="<%= submission_path(submission) %>" class="z-[1] tooltip flex" data-tip="<%= l(submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge md:w-24 <%= status_badges[submitter.status] %> bg-opacity-50 uppercase text-xs font-semibold">
<%= t(submitter.status) %>
@ -144,7 +156,7 @@
<a href="<%= submission_path(submission) %>" class="text-lg break-all peer">
<%= submitter.name || submitter.email || submitter.phone %>
</a>
<% if can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %>
<% if can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.voided_at? && !submission.expired? && !submitter.declined_at? %>
<span class="pl-0.5 tooltip tooltip-top md:opacity-0 md:hover:opacity-100 md:peer-hover:opacity-100" data-tip="<%= t('edit') %>">
<%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('pencil', class: 'w-5 h-5') %>
@ -164,7 +176,7 @@
<span class="inline"><%= t('download')[..-2] %>...</span>
</span>
</download-button>
<% elsif !template&.archived_at? && !submission.archived_at? && !is_submission_completed && !submission.expired? && !submitter.declined_at? %>
<% elsif !template&.archived_at? && !submission.archived_at? && !submission.voided_at? && !is_submission_completed && !submission.expired? && !submitter.declined_at? %>
<div class="relative flex items-center space-x-3">
<% if current_user.email == submitter.email %>
<a href="<%= submit_form_path(slug: submitter.slug) %>" data-turbo="false" target="_blank" id="sign_yourself_button" class="absolute md:relative top-0 right-0 btn btn-xs btn-outline btn-neutral bg-white w-28 md:w-36 z-[1]">
@ -208,7 +220,7 @@
<%= t('view') %>
</a>
</div>
<% if !submission.archived_at? && !template&.archived_at? %>
<% if !submission.archived_at? && !submission.voided_at? && !template&.archived_at? %>
<span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: nil, icon: svg_icon('archive', class: 'w-6 h-6'), icon_disabled: svg_icon('loader', class: 'w-6 h-6 animate-spin')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>
</span>

@ -564,10 +564,28 @@ en: &en
new_recipients_have_been_added: New recipients have been added.
submission_has_been_removed: Submission has been removed.
submission_has_been_archived: Submission has been archived.
submission_has_been_voided: Submission has been voided.
submission_cannot_be_voided: This submission cannot be voided.
voided_submission_cannot_be_unarchived: A voided submission cannot be unarchived.
void_reason_is_required: A reason is required to void this submission.
form_has_been_completed_already: Form has been completed already.
form_has_been_archived: Form has been archived.
form_has_been_expired: Form has been expired.
form_has_been_declined: Form has been declined.
form_has_been_voided: Form has been voided.
form_has_been_voided_on_html: 'Form has been voided on <span class="font-semibold">%{time}</span>.'
voided_by_name: 'Voided by %{name}'
void: Void
voided: Voided
void_submission: Void submission
void_submission_confirmation: Voiding stops all signing on this document. The recipients will be notified with the reason you provide. This cannot be undone.
enter_reason_for_voiding: Enter a reason for voiding this document
cancel: Cancel
name_voided_by_sender: '"%{name}" has been voided'
name_has_been_voided_with_reason_html: '"%{name}" has been voided by %{sender}.'
voided_email_no_action_required: No further action is required from you. If you have any questions, please reply to this email.
voided_watermark_pending: Voided document with watermark is being generated…
voided_on_time: 'Voided on %{time}'
file_is_missing: File is missing
folder_name_has_been_updated: Folder name has been updated.
unable_to_rename_folder: Unable to rename folder.

@ -31,6 +31,7 @@ Rails.application.routes.draw do
resources :submitters, only: %i[index show update]
resources :submissions, only: %i[index show create destroy] do
resources :documents, only: %i[index], controller: 'submission_documents'
resources :void, only: %i[create], controller: 'submissions_void'
collection do
resources :init, only: %i[create], controller: 'submissions'
resources :emails, only: %i[create], controller: 'submissions', as: :submissions_emails
@ -70,6 +71,7 @@ Rails.application.routes.draw do
resources :submissions, only: %i[index], controller: 'submissions_dashboard'
resources :submissions, only: %i[show destroy] do
resources :unarchive, only: %i[create], controller: 'submissions_unarchive'
resources :void, only: %i[new create], controller: 'submissions_void'
resources :events, only: %i[index], controller: 'submission_events'
resources :download, only: %i[index], controller: 'submissions_download'
end

@ -0,0 +1,12 @@
# frozen_string_literal: true
class AddVoidedAtToSubmissions < ActiveRecord::Migration[8.1]
def change
add_column :submissions, :voided_at, :datetime
add_index :submissions,
%i[account_id template_id id],
where: 'voided_at IS NOT NULL',
name: :index_submissions_on_account_id_and_template_id_and_id_voided
end
end

@ -51,6 +51,8 @@ module Submissions
submissions.declined
when 'expired'
submissions.expired
when 'voided'
submissions.voided
when 'sent'
submissions.joins(:submitters)
.where(submitters: { opened_at: nil, completed_at: nil, declined_at: nil })

@ -0,0 +1,152 @@
# frozen_string_literal: true
module Submissions
module GenerateVoidedDocuments
WATERMARK_COLOR = [0.85, 0.10, 0.10].freeze
WATERMARK_STROKE_OPACITY = 0.85
WATERMARK_FONT_SIZE = 150
WATERMARK_STROKE_WIDTH = 2.2
FOOTER_FONT_SIZE = 10
FOOTER_HEIGHT = 36
module_function
def call(submission)
return [] unless submission.voided_at?
submission.with_lock do
source_documents = source_attachments(submission)
.select { |a| a.blob.content_type == 'application/pdf' }
.uniq { |a| a.blob.checksum }
existing = submission.voided_documents.attachments.reload
return existing.to_a if existing.size == source_documents.size && existing.any?
submission.voided_documents.purge if existing.any?
voided_by = submission.voided_by_user
reason = submission.void_reason
voided_at = submission.voided_at
source_documents.each do |source|
watermarked_io = stamp_pdf(source.download, voided_at:, voided_by:, reason:)
submission.voided_documents.attach(
io: watermarked_io,
filename: build_filename(source.filename),
content_type: 'application/pdf'
)
end
end
submission.voided_documents.attachments.reload.to_a
end
def source_attachments(submission)
if submission.documents.attached?
submission.documents.attachments
elsif submission.template
submission.template.documents_attachments.to_a
else
[]
end
end
def stamp_pdf(pdf_data, voided_at:, voided_by:, reason:)
doc = HexaPDF::Document.new(io: StringIO.new(pdf_data))
doc.pages.each do |page|
draw_watermark_on_page(doc, page)
draw_footer_on_page(doc, page, voided_at:, voided_by:, reason:)
end
out = StringIO.new
doc.write(out, validate: false)
out.tap(&:rewind)
end
def draw_watermark_on_page(doc, page)
box = page.box(:media)
width = box.width
height = box.height
font = doc.fonts.add('Helvetica', variant: :bold)
fragment = HexaPDF::Layout::TextFragment.create(
'VOIDED',
font:,
font_size: WATERMARK_FONT_SIZE,
stroke_color: WATERMARK_COLOR,
stroke_width: WATERMARK_STROKE_WIDTH,
text_rendering_mode: :stroke,
stroke_alpha: WATERMARK_STROKE_OPACITY
)
text_width = fragment.width
text_height = fragment.height
canvas = page.canvas(type: :overlay)
canvas.save_graphics_state do
center_x = width / 2.0
center_y = height / 2.0
angle_rad = Math::PI / 6
cos_a = Math.cos(angle_rad)
sin_a = Math.sin(angle_rad)
offset_x = -(text_width / 2.0) * cos_a + (text_height / 2.0) * sin_a
offset_y = -(text_width / 2.0) * sin_a - (text_height / 2.0) * cos_a
canvas.transform(cos_a, sin_a, -sin_a, cos_a, center_x + offset_x, center_y + offset_y)
fragment.draw(canvas, 0, 0)
end
end
def draw_footer_on_page(doc, page, voided_at:, voided_by:, reason:)
box = page.box(:media)
width = box.width
canvas = page.canvas(type: :overlay)
canvas.save_graphics_state do
canvas.fill_color(0.95, 0.20, 0.20)
canvas.rectangle(0, 0, width, FOOTER_HEIGHT).fill
end
font = doc.fonts.add('Helvetica', variant: :bold)
white = [1.0, 1.0, 1.0]
line1 = build_footer_line1(voided_at, voided_by)
line2 = build_footer_line2(reason)
[line1, line2].each_with_index do |text, idx|
next if text.blank?
fragment = HexaPDF::Layout::TextFragment.create(text, font:, font_size: FOOTER_FONT_SIZE,
fill_color: white)
canvas.save_graphics_state do
fragment.draw(canvas, 12, FOOTER_HEIGHT - 14 - (idx * 14))
end
end
end
def build_footer_line1(voided_at, voided_by)
timestamp = voided_at.utc.strftime('%Y-%m-%d %H:%M UTC')
voided_by_label = voided_by ? (voided_by.full_name.presence || voided_by.email) : 'an authorized user'
"VOIDED on #{timestamp} by #{voided_by_label}"
end
def build_footer_line2(reason)
return nil if reason.blank?
"Reason: #{reason.to_s.tr("\r\n", ' ').squeeze(' ').strip[0, 200]}"
end
def build_filename(original_filename)
base = original_filename.base.to_s
ext = original_filename.extension.presence || 'pdf'
"#{base}-voided.#{ext}"
end
end
end

@ -3,7 +3,7 @@
module Submissions
module SerializeForApi
SERIALIZE_PARAMS = {
only: %i[id name slug source submitters_order expire_at created_at updated_at archived_at],
only: %i[id name slug source submitters_order expire_at created_at updated_at archived_at voided_at],
include: {
submitters: { only: %i[id] },
template: { only: %i[id name external_id created_at updated_at],
@ -37,7 +37,19 @@ module Submissions
json['fields'] = submission.template_fields || submission.template&.fields
end
if submitters.all?(&:completed_at?)
if submission.voided_at?
if with_documents
voided_attachments = submission.voided_documents.attachments.to_a
json['documents'] = voided_attachments.map do |att|
{ name: att.filename.base, url: ActiveStorage::Blob.proxy_url(att.blob, expires_at:) }
end
end
json['audit_log_url'] = nil
json['combined_document_url'] = nil
json['status'] = 'voided'
json['completed_at'] = nil
json['void_reason'] = submission.void_reason
elsif submitters.all?(&:completed_at?)
last_submitter = submitters.max_by(&:completed_at)
if with_documents

@ -0,0 +1,46 @@
# frozen_string_literal: true
module Submissions
module Void
NotVoidableError = Class.new(StandardError)
ReasonRequiredError = Class.new(StandardError)
module_function
def call(submission, user:, reason:, request: nil)
reason = reason.to_s.strip
raise ReasonRequiredError, I18n.t('void_reason_is_required') if reason.blank?
raise NotVoidableError, I18n.t('submission_cannot_be_voided') unless submission.voidable?
ApplicationRecord.transaction do
submission.update!(voided_at: Time.current)
SubmissionEvent.create!(
submission:,
event_type: :void_submission,
data: {
reason:,
voided_by_user_id: user&.id,
ip: request&.remote_ip,
ua: request&.user_agent
}.compact_blank
)
end
notify_submitters(submission, user)
WebhookUrls.enqueue_events(submission, 'submission.voided')
GenerateVoidedDocumentsJob.perform_async('submission_id' => submission.id)
submission
end
def notify_submitters(submission, user)
submission.submitters.each do |submitter|
next if submitter.email.blank?
next if submitter.sent_at.blank?
SubmitterMailer.voided_email(submitter, user).deliver_later!
end
end
end
end

@ -10,6 +10,7 @@ module WebhookUrls
'submission.completed' => SendSubmissionCompletedWebhookRequestJob,
'submission.expired' => SendSubmissionExpiredWebhookRequestJob,
'submission.archived' => SendSubmissionArchivedWebhookRequestJob,
'submission.voided' => SendSubmissionVoidedWebhookRequestJob,
'template.created' => SendTemplateCreatedWebhookRequestJob,
'template.updated' => SendTemplateUpdatedWebhookRequestJob,
'template.archived' => SendTemplateArchivedWebhookRequestJob

Loading…
Cancel
Save