Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4d57fd3
add sponsor limits
alinvetian Feb 20, 2026
1dbbd33
rubocop fix
alinvetian Feb 20, 2026
c9cc84f
rubocop fix
alinvetian Feb 20, 2026
39fbaf4
update payment forms
alinvetian Feb 20, 2026
97678d8
udate specs
alinvetian Feb 23, 2026
ddbb7e3
added more specs
alinvetian Feb 23, 2026
e6cdaeb
payer must cover LDF
alinvetian Feb 24, 2026
85aa020
Merge branch 'main' of github.com:datadryad/dryad-app into 4817-conso…
alinvetian Feb 24, 2026
edbceb4
rubocop
alinvetian Feb 24, 2026
e8a2cdb
specs fixes
alinvetian Feb 24, 2026
9379a50
payment system updates
alinvetian Feb 25, 2026
e174332
specs fixes
alinvetian Feb 26, 2026
ec2e292
Merge branch 'main' of github.com:datadryad/dryad-app into 4817-conso…
alinvetian Feb 26, 2026
8daf2da
rollback change
alinvetian Feb 26, 2026
80f0cb1
logging updates and tests
alinvetian Feb 26, 2026
e67242d
Merge branch 'main' of github.com:datadryad/dryad-app into 4817-conso…
alinvetian Feb 27, 2026
c2330d0
handle version delete
alinvetian Feb 27, 2026
72fb715
handle files deletion on newer versions
alinvetian Feb 27, 2026
a5ed585
changing sponsor, resets file size and sponsored payments
alinvetian Mar 2, 2026
f907b34
Merge pull request #2780 from datadryad/4878-missing-consideration-fo…
alinvetian Mar 2, 2026
ef7309d
fixed merge conflicts
alinvetian Mar 2, 2026
9414506
update bad response
alinvetian Mar 3, 2026
c6a9057
Merge branch 'main' of github.com:datadryad/dryad-app into 4817-conso…
alinvetian Mar 5, 2026
5a04485
File size limits are in MB for dev
alinvetian Mar 5, 2026
0265000
rubocop
alinvetian Mar 5, 2026
5e35d08
update tests
alinvetian Mar 5, 2026
b0f755c
update tests
alinvetian Mar 5, 2026
741bc85
Update tenant connect_list
alinvetian Mar 17, 2026
3f18de2
Merge branch 'main' of github.com:datadryad/dryad-app into 4817-conso…
alinvetian Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion app/controllers/stash_engine/resources_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ def display_readme
end

def dpc_status
user_payer_aff = StashEngine::Tenant.find_by_ror_id(@resource.identifier&.submitter_affiliation&.ror_id)&.connect_list
aff_tenant = if @resource.tenant_id.in?(user_payer_aff.ids)
user_payer_aff.find_by(id: @resource.tenant_id)
else
user_payer_aff.first
end

@resource.check_add_readme_file
@resource.check_add_cedar_json
dpc_checks = {
Expand All @@ -161,7 +168,7 @@ def dpc_status
institution_will_pay: @resource.identifier.institution_will_pay?,
funder_will_pay: @resource.identifier.funder_will_pay?,
user_must_pay: @resource.identifier.user_must_pay?,
aff_tenant: StashEngine::Tenant.find_by_ror_id(@resource.identifier&.submitter_affiliation&.ror_id)&.connect_list&.first,
aff_tenant: aff_tenant,
allow_review: @resource.identifier.allow_review?,
automatic_ppr: @resource.identifier.automatic_ppr?,
man_decision_made: @resource.identifier.has_accepted_manuscript? || @resource.identifier.has_rejected_manuscript?
Expand Down
2 changes: 1 addition & 1 deletion app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def readme_render(content)
end

def ldf_pricing_tiers_options
[['No limit', '']] + FeeCalculator::BaseService::ESTIMATED_FILES_SIZE.map do |tier|
[['No limit', '']] + ESTIMATED_FILES_SIZE.map do |tier|
["#{filesize(tier[:range].max)} ($#{tier[:price]})", tier[:tier]]
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,40 @@
<p><i className="fas fa-spinner fa-spin" role="img" aria-label="Loading..." /></p>
);
}

const TermsOfSubmission = () => {

Check failure on line 108 in app/javascript/react/components/MetadataEntry/Agreements/Agreements.jsx

View workflow job for this annotation

GitHub Actions / build

Function component is not a function declaration

Check failure on line 108 in app/javascript/react/components/MetadataEntry/Agreements/Agreements.jsx

View workflow job for this annotation

GitHub Actions / build

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “Agreements” and pass data as props
if(!preview) return null;

Check failure on line 109 in app/javascript/react/components/MetadataEntry/Agreements/Agreements.jsx

View workflow job for this annotation

GitHub Actions / build

Expected space(s) after "if"
if(resource.accepted_agreement) {

Check failure on line 110 in app/javascript/react/components/MetadataEntry/Agreements/Agreements.jsx

View workflow job for this annotation

GitHub Actions / build

Expected space(s) after "if"
return (
<p>
<i className="fas fa-circle-check" aria-hidden="true" />{' '}
The submitter has agreed to Dryad&apos;s{' '}
<a href="/terms" target="_blank">terms of submission<ExitIcon /></a>
</p>
)

Check failure on line 117 in app/javascript/react/components/MetadataEntry/Agreements/Agreements.jsx

View workflow job for this annotation

GitHub Actions / build

Missing semicolon
}

Check failure on line 118 in app/javascript/react/components/MetadataEntry/Agreements/Agreements.jsx

View workflow job for this annotation

GitHub Actions / build

Closing curly brace does not appear on the same line as the subsequent block
else {

Check failure on line 119 in app/javascript/react/components/MetadataEntry/Agreements/Agreements.jsx

View workflow job for this annotation

GitHub Actions / build

Unnecessary 'else' after 'return'
return (
<p style={{fontStyle: 'italic'}}><i className="fas fa-square" aria-hidden="true" />{' '} Terms not yet accepted</p>
)

Check failure on line 122 in app/javascript/react/components/MetadataEntry/Agreements/Agreements.jsx

View workflow job for this annotation

GitHub Actions / build

Missing semicolon
}
}

Check failure on line 124 in app/javascript/react/components/MetadataEntry/Agreements/Agreements.jsx

View workflow job for this annotation

GitHub Actions / build

Missing semicolon

const NoSubmitterWarning = () => {

Check failure on line 126 in app/javascript/react/components/MetadataEntry/Agreements/Agreements.jsx

View workflow job for this annotation

GitHub Actions / build

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “Agreements” and pass data as props
if(preview || isSubmitter) return null;

return (
<div className="callout warn">
<p>
Only the submitter can agree to the terms and conditions.
When you are done editing, please click &nbsp;
<b><i className="fas fa-floppy-disk" /> Save &amp; exit</b> &nbsp;
and ask the submitter to complete the submission.
</p>
</div>
)
}

return (
<>
{preview && (
Expand Down Expand Up @@ -207,16 +241,7 @@
)}
</>
)}
{!preview && !isSubmitter && (
<div className="callout warn">
<p>
Only the submitter can agree to the terms and conditions.
When you are done editing, please click &nbsp;
<b><i className="fas fa-floppy-disk" /> Save &amp; exit</b> &nbsp;
and ask the submitter to complete the submission.
</p>
</div>
)}
<NoSubmitterWarning />
{isSubmitter && (
<>
{(subType !== 'collection'
Expand All @@ -229,7 +254,7 @@
<div style={{maxWidth: '700px'}} ref={formRef} />
</>
)}
{userMustPay && (
{userMustPay && !!dpc.aff_tenant && dpc.aff_tenant.id !== resource.tenant_id && (
<div className="callout warn" style={{margin: '1em 0', paddingBottom: '5px'}}>
<p style={{marginBottom: '.75em'}}>
<i className="fas fa-circle-question" aria-hidden="true" style={{marginRight: '.5ch'}} />
Expand Down Expand Up @@ -280,19 +305,9 @@
)}
</>
)}
{preview && (
<div>
{resource.accepted_agreement ? (
<p>
<i className="fas fa-circle-check" aria-hidden="true" />{' '}
The submitter has agreed to Dryad&apos;s{' '}
<a href="/terms" target="_blank">terms of submission<ExitIcon /></a>
</p>
) : (
<p style={{fontStyle: 'italic'}}><i className="fas fa-square" aria-hidden="true" />{' '} Terms not yet accepted</p>
)}
</div>
)}
<TermsOfSubmission />
</>
);


}
5 changes: 4 additions & 1 deletion app/models/payment_configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ class PaymentConfiguration < ApplicationRecord
private

def reset_limit
self.ldf_limit = nil unless covers_ldf
return if covers_ldf

self.ldf_limit = nil
self.yearly_ldf_limit = nil
end
end
5 changes: 5 additions & 0 deletions app/models/sponsored_payment_log.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,24 @@
# Table name: sponsored_payment_logs
#
# id :bigint not null, primary key
# deleted_at :datetime
# dpc :integer
# ldf :integer
# payer_type :string(191)
# created_at :datetime not null
# updated_at :datetime not null
# payer_id :string(191)
# resource_id :integer
# sponsor_id :string(191)
#
# Indexes
#
# index_sponsored_payment_logs_on_deleted_at (deleted_at)
# index_sponsored_payment_logs_on_payer_id_and_payer_type (payer_id,payer_type)
# index_sponsored_payment_logs_on_sponsor_id (sponsor_id)
#
class SponsoredPaymentLog < ApplicationRecord
acts_as_paranoid

belongs_to :payer, polymorphic: true
belongs_to :resource, class_name: StashEngine::Resource.to_s
Expand Down
1 change: 1 addition & 0 deletions app/models/stash_engine/identifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class Identifier < ApplicationRecord
belongs_to :software_license, class_name: 'StashEngine::SoftwareLicense', optional: true
has_many :curation_activities, class_name: 'StashEngine::CurationActivity', through: :resources
has_many :payments, class_name: 'ResourcePayment', through: :resources
has_many :sponsored_payment_logs, through: :resources

after_create :create_process_date, unless: :process_date
after_create :create_share
Expand Down
1 change: 1 addition & 0 deletions app/models/stash_engine/resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ class Resource < ApplicationRecord # rubocop:disable Metrics/ClassLength
has_one :flag, class_name: 'StashEngine::Flag', as: :flaggable, dependent: :destroy
has_many :flags, ->(resource) { unscope(where: :resource_id).where(flaggable: [resource.journal, resource.tenant, resource.users]) }
has_one :payment, class_name: 'ResourcePayment'
has_one :sponsored_payment_log, dependent: :destroy

after_create :create_process_date, unless: :process_date
after_update_commit :update_salesforce_metadata, if: [:saved_change_to_user_id?, proc { |res| res.curator&.min_curator? }]
Expand Down
8 changes: 5 additions & 3 deletions app/models/stash_engine/support/payment_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ module PaymentMethods
def user_must_pay?
return false if latest_resource.resource_type&.resource_type == 'collection'
return false if waiver? && old_payment_system
return PaymentLimitsService.new(latest_resource, payer).limits_exceeded? if sponsored?
return PaymentLimitsService.new(latest_resource, PayersService.new(payer).payment_sponsor).limits_exceeded? if sponsored?

true
end
Expand Down Expand Up @@ -121,8 +121,7 @@ def institution_will_pay?

# do not remove recorded institution sponsor due to sponsorship change
return true if payment_id.present? && payment_id == tenant&.id

return false unless tenant&.payment_configuration&.covers_dpc
return false unless PayersService.new(tenant).payment_sponsor&.payment_configuration&.covers_dpc

if tenant&.authentication&.strategy == 'author_match'
# get all unique ror_id associations for all authors
Expand Down Expand Up @@ -181,7 +180,10 @@ def clear_payment_for_changed_sponsor

self.payment_type = nil
self.payment_id = nil
self.last_invoiced_file_size = 0
save

sponsored_payment_logs.destroy_all
reload
end
end
Expand Down
32 changes: 31 additions & 1 deletion app/models/stash_engine/tenant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,32 @@ class Tenant < ApplicationRecord
# return all enabled tenants sorted by name
scope :enabled, -> { where(enabled: true).order(:short_name) }
scope :partner_list, -> { enabled.where(partner_display: true) }
scope :connect_list, -> { partner_list.joins(:payment_configuration).where(payment_configurations: { covers_dpc: true }) }
scope :connect_list, -> {
# Get all root tenant IDs that have covers_dpc: true
root_ids = joins(:payment_configuration)
.where(sponsor_id: nil, payment_configurations: { covers_dpc: true })
.pluck(:id)

return none if root_ids.empty?

# Use a recursive CTE to get all descendants of those roots
partner_list.where(<<~SQL, root_ids: root_ids)
stash_engine_tenants.id IN (
WITH RECURSIVE tenant_tree AS (
SELECT id, sponsor_id
FROM stash_engine_tenants
WHERE id IN (:root_ids)

UNION ALL

SELECT t.id, t.sponsor_id
FROM stash_engine_tenants t
INNER JOIN tenant_tree tt ON t.sponsor_id = tt.id
)
SELECT id FROM tenant_tree
)
SQL
}
scope :tiered, -> { enabled.joins(:payment_configuration).where(payment_configurations: { payment_plan: 'TIERED' }) }
scope :fees_2025, -> { enabled.joins(:payment_configuration).where(payment_configurations: { payment_plan: '2025' }) }
scope :sponsored, -> { enabled.distinct.joins(:sponsored) }
Expand Down Expand Up @@ -114,5 +139,10 @@ def full_url(path)
end
end

def payment_sponsor
sponsor_obj = self
sponsor_obj = sponsor_obj.sponsor while sponsor_obj.sponsor.present?
sponsor_obj
end
end
end
2 changes: 1 addition & 1 deletion app/services/curation_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ def process_payment
end

def processed_sponsored_resource
return unless @status.in?(%w[queued peer_review])
return unless @status.in?(%w[processing queued peer_review])

SponsoredPaymentsService.new(@resource).log_payment
end
Expand Down
77 changes: 37 additions & 40 deletions app/services/fee_calculator/base_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,13 @@ module FeeCalculator
class BaseService
attr_reader :options, :resource

# rubocop:disable Layout/SpaceInsideRangeLiteral, Layout/ExtraSpacing
ESTIMATED_DATASETS = [
{ tier: 1, range: 0.. 5, price: 0 },
{ tier: 2, range: 6.. 15, price: 1_650 },
{ tier: 3, range: 16.. 25, price: 2_700 },
{ tier: 4, range: 26.. 50, price: 5_350 },
{ tier: 5, range: 51.. 75, price: 7_950 },
{ tier: 6, range: 76..100, price: 10_500 },
{ tier: 7, range: 101..150, price: 15_600 },
{ tier: 8, range: 151..200, price: 20_500 },
{ tier: 9, range: 201..250, price: 25_500 },
{ tier: 10, range: 251..300, price: 30_250 },
{ tier: 11, range: 301..350, price: 35_000 },
{ tier: 12, range: 351..400, price: 39_500 },
{ tier: 13, range: 401..450, price: 44_000 },
{ tier: 14, range: 451..500, price: 48_750 },
{ tier: 15, range: 501..550, price: 53_500 },
{ tier: 16, range: 551..600, price: 58_250 }
].freeze

ESTIMATED_FILES_SIZE = [
{ tier: 0, range: 0.. 10_000_000_000, price: 0 },
{ tier: 1, range: 10_000_000_001.. 50_000_000_000, price: 259 },
{ tier: 2, range: 50_000_000_001.. 100_000_000_000, price: 464 },
{ tier: 3, range: 100_000_000_001.. 250_000_000_000, price: 1_123 },
{ tier: 4, range: 250_000_000_001.. 500_000_000_000, price: 2_153 },
{ tier: 5, range: 500_000_000_001..1_000_000_000_000, price: 4_347 },
{ tier: 6, range: 1_000_000_000_001..2_000_000_000_000, price: 8_809 }
].freeze

INVOICE_FEE = 199
# rubocop:enable Layout/SpaceInsideRangeLiteral, Layout/ExtraSpacing

def initialize(options = {}, resource: nil, payer_record: nil)
@sum = 0
@options = options
@sum_options = {}
@resource = resource
@payer = payer_record || (resource ? resource.identifier.payer : nil)
@payer = PayersService.new(@payer).payment_sponsor if @payer
@payment_plan_is_2025 = resource ? resource.identifier.payer_2025?(@payer) : false
@covers_ldf = resource ? @payer&.payment_configuration&.covers_ldf : false
@ldf_limit = resource ? @payer&.payment_configuration&.ldf_limit : nil
Expand All @@ -53,11 +21,23 @@ def call
if resource.present?
add_zero_fee(:service_tier)
add_zero_fee(:dpc_tier)
limits_service = PaymentLimitsService.new(resource, @payer, ldf_sponsored_amount: ldf_sponsored_amount)

if @covers_ldf
if @ldf_limit.nil?
@sum_options[:storage_fee_label] = PRODUCT_NAME_MAPPER[:storage_fee_overage] unless @ldf_limit.nil?
if @ldf_limit.nil? && limits_service.payment_allowed?
# if no limit is hit,
# the user pays no storage fee
verify_max_storage_size
add_zero_fee(:storage_size)
elsif limits_service.amount_limits_exceeded?
# if the yearly amount limit is hit,
# the user needs to pay the full storage difference
add_storage_fee_difference
add_invoice_fee
else
# if the amount by adding sponsored storage fee is not exceeded
# user mult pay the difference between sponsored size and resource size
handle_ldf_limit
end
else
Expand Down Expand Up @@ -90,10 +70,33 @@ def handle_ldf_limit
add_invoice_fee
end

def ldf_sponsored_amount(paid_storage_size: nil)
paid_storage_size ||= resource.identifier.last_invoiced_file_size.to_i
paid_tier_price = price_by_range(storage_fee_tiers, paid_storage_size)

new_tier_price = if @ldf_limit.nil?
price_by_range(storage_fee_tiers, resource.total_file_size)
else
new_tier_price = price_by_range(storage_fee_tiers, resource.total_file_size)
tier = get_tier_by_value(storage_fee_tiers, @ldf_limit)
[new_tier_price, tier[:price]].min
end

diff = new_tier_price - paid_tier_price
diff = 0 if diff < 0
diff.to_f
end

def storage_fee_tier
get_tier_by_range(storage_fee_tiers, resource.total_file_size)
end

# if tier is not matched, consider first tier
def get_tier_by_value(tier_definition, value)
tier = tier_definition.find { |t| t[:tier] == value.to_i }
tier || tier_definition.find { |t| t[:tier] == 1 }
end

private

def verify_new_payment_system
Expand Down Expand Up @@ -192,12 +195,6 @@ def price_by_tier(tier_definition, value)
tier[:price].to_i
end

# if tier is not matched, consider first tier
def get_tier_by_value(tier_definition, value)
tier = tier_definition.find { |t| t[:tier] == value.to_i }
tier || tier_definition.find { |t| t[:tier] == 1 }
end

def add_fee_by_range(tier_definition, value_key)
value = price_by_range(tier_definition, options[value_key])
add_fee_to_total(value_key, value)
Expand Down
16 changes: 0 additions & 16 deletions app/services/fee_calculator/individual_service.rb
Original file line number Diff line number Diff line change
@@ -1,21 +1,5 @@
module FeeCalculator
class IndividualService < BaseService
# rubocop:disable Layout/SpaceInsideRangeLiteral, Layout/ExtraSpacing
INDIVIDUAL_ESTIMATED_FILES_SIZE = [
{ tier: 1, range: 0.. 5_000_000_000, price: 150 },
{ tier: 2, range: 5_000_000_001.. 10_000_000_000, price: 180 },
{ tier: 3, range: 10_000_000_001.. 50_000_000_000, price: 520 },
{ tier: 4, range: 50_000_000_001.. 100_000_000_000, price: 808 },
{ tier: 5, range: 100_000_000_001.. 250_000_000_000, price: 1_750 },
{ tier: 6, range: 250_000_000_001.. 500_000_000_000, price: 3_086 },
{ tier: 7, range: 500_000_000_001..1_000_000_000_000, price: 6_077 },
{ tier: 8, range: 1_000_000_000_001..2_000_000_000_000, price: 12_162 }
].freeze

PPR_FEE = 50
PPR_COUPON_ID = 'PPR_DISCOUNT_2025'.freeze
# rubocop:enable Layout/SpaceInsideRangeLiteral, Layout/ExtraSpacing

def call
verify_new_payment_system
verify_max_storage_size if resource
Expand Down
Loading
Loading