Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 25 additions & 12 deletions app/models/certification/funding_request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ module Certification
# design ("I need Funding") stage. Routes through the same reviewer queue as
# ship certifications (Certification::Reviewable). On approval the project
# switches to the build stage and the owner accrues an Outpost Ticket discount
# for every dollar they didn't request within their tier.
# set by the approved tier (B 30% / A 50% / S & X 100% of the ticket price).
class FundingRequest < ApplicationRecord
self.table_name = "certification_funding_requests"

Expand All @@ -62,22 +62,34 @@ class FundingRequest < ApplicationRecord
# Complexity tiers, mirroring outpost.hackclub.com (B/A/S/X). Keyed by the
# integer stored in complexity_tier; each carries a max grant + examples.
TIERS = {
1 => { code: "B", name: "B Tier", max_cents: 2_500, examples: "Macropads and very basic PCBs" },
2 => { code: "A", name: "A Tier", max_cents: 12_000, examples: "Keyboards and devboards" },
3 => { code: "S", name: "S Tier", max_cents: 18_000, examples: "Ambitious, polished builds" },
4 => { code: "X", name: "X Tier", max_cents: 40_000, examples: "Out of this world builds (may include a travel stipend)" }
1 => { code: "B", name: "B Tier", max_cents: 2_500, discount_percent: 30, examples: "Macropads and very basic PCBs" },
2 => { code: "A", name: "A Tier", max_cents: 12_000, discount_percent: 50, examples: "Keyboards and devboards" },
3 => { code: "S", name: "S Tier", max_cents: 18_000, discount_percent: 100, examples: "Ambitious, polished builds" },
4 => { code: "X", name: "X Tier", max_cents: 40_000, discount_percent: 100, examples: "Out of this world builds (may include a travel stipend)" }
}.freeze

# tier => maximum grant, in cents / dollars.
TIER_MAX_CENTS = TIERS.transform_values { |t| t[:max_cents] }.freeze
TIER_MAX_DOLLARS = TIER_MAX_CENTS.transform_values { |cents| cents / 100 }.freeze

# Stardust knocked off the Outpost Ticket per dollar left unrequested.
DISCOUNT_STARDUST_PER_DOLLAR = 2
# tier => percent of the Outpost Ticket price knocked off when a design is
# approved at that tier. Flat per tier — no longer tied to unrequested dollars.
TIER_DISCOUNT_PERCENT = TIERS.transform_values { |t| t[:discount_percent] }.freeze

# Stardust a reviewer earns per completed funding review.
REVIEW_BOUNTY = 1

# tier id => { code:, pct:, sd: } for client-side previews (funding modal).
def self.tier_discount_summary
TIERS.each_with_object({}) do |(id, t), summary|
summary[id] = {
code: t[:code],
pct: t[:discount_percent],
sd: (t[:discount_percent] * User::OUTPOST_TICKET_BASE / 100.0).round
}
end
end

validates :complexity_tier, inclusion: { in: TIER_MAX_CENTS.keys }
validates :requested_amount_cents, numericality: { only_integer: true, greater_than: 0 }
validates :approved_amount_cents,
Expand Down Expand Up @@ -164,6 +176,9 @@ def tier_examples = tier[:examples]
def tier_label = tier_name || "Tier #{complexity_tier}"
def tier_max_cents = tier[:max_cents]
def tier_max_dollars = tier_max_cents ? tier_max_cents / 100 : nil
def tier_discount_percent = tier[:discount_percent].to_i
# Flat Stardust knocked off the Outpost Ticket for this tier.
def tier_discount_stardust = (tier_discount_percent * User::OUTPOST_TICKET_BASE / 100.0).round
def requested_amount_dollars = (requested_amount_cents || 0) / 100
def final_amount_cents = approved_amount_cents || requested_amount_cents
def final_amount_dollars = (final_amount_cents || 0) / 100
Expand Down Expand Up @@ -211,8 +226,7 @@ def requested_within_tier_max
end
end

# Reviewers can approve for less than requested, but never above the tier max
# (keeps the unrequested-dollar discount non-negative).
# Reviewers can approve for less than requested, but never above the tier max.
def approved_within_tier_max
return if approved_amount_cents.blank? || complexity_tier.blank?
return unless TIER_MAX_CENTS.key?(complexity_tier)
Expand Down Expand Up @@ -254,15 +268,14 @@ def apply_verdict_to_project!
end
end

# 2 Stardust per unrequested dollar within the tier, cumulative on the owner.
# Flat per-tier discount toward the Outpost Ticket, cumulative on the owner.
# Snapshotted into discount_stardust_awarded so re-saving an approved request
# never double-accrues.
def accrue_discount_for_owner!
return unless approved?
return if discount_stardust_awarded.present?

unused_dollars = [ (tier_max_cents - final_amount_cents) / 100, 0 ].max
awarded = unused_dollars * DISCOUNT_STARDUST_PER_DOLLAR
awarded = tier_discount_stardust

owner = project.memberships.owner.first&.user || user
owner.with_lock do
Expand Down
2 changes: 1 addition & 1 deletion app/models/shop_item/outpost_ticket.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
# fk_rails_... (user_id => users.id)
#
# The Outpost Ticket: locked behind the "presentable hardware project" flag and
# discounted per-user by the Stardust accrued from unrequested funding dollars.
# discounted per-user by the Stardust accrued from approved funding tiers.
class ShopItem::OutpostTicket < ShopItem
# Effective price = base price minus the user's accrued Outpost discount,
# floored at 0. The overflow past the base becomes a flight stipend
Expand Down
4 changes: 2 additions & 2 deletions app/views/admin/certification/funding_requests/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@
value: (funding_request.approved_amount_dollars || funding_request.requested_amount_dollars),
min: 0, max: funding_request.tier_max_dollars, step: 1 %>
<p class="review-form__hint">
Requested $<%= funding_request.requested_amount_dollars %>. Every unrequested dollar earns the owner
<%= Certification::FundingRequest::DISCOUNT_STARDUST_PER_DOLLAR %> Stardust toward the Outpost Ticket.
Requested $<%= funding_request.requested_amount_dollars %>. Approving earns the owner the <%= funding_request.tier_label %> discount:
<%= funding_request.tier_discount_percent %>% (✦<%= funding_request.tier_discount_stardust %>) off the Outpost Ticket.
</p>
</div>

Expand Down
10 changes: 5 additions & 5 deletions app/views/guides/topics/_tiers.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@
Got a build with a cool twist and real polish? Reviewers can bump it up a tier.
</p>

<h2>Spend less, earn Stardust</h2>
<h2>Earn your Outpost Ticket</h2>
<p>
You don't have to request your whole tier. For every dollar you leave on the
table, you earn <strong><%= Certification::FundingRequest::DISCOUNT_STARDUST_PER_DOLLAR %> Stardust</strong>
toward the <strong>Outpost Ticket</strong> in the shop. Once your accrued
discount covers the ticket, the extra rolls into a <strong>flight stipend</strong>.
When your design is approved, your tier discounts the <strong>Outpost Ticket</strong>
in the shop: <strong>B tier 30% off</strong>, <strong>A tier 50% off</strong>, and
<strong>S and X tiers a full 100% off</strong>. Stack approvals past the ticket
price and the extra rolls into a <strong>flight stipend</strong>.
</p>

<h2>After your grant</h2>
Expand Down
17 changes: 8 additions & 9 deletions app/views/projects/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -1023,7 +1023,7 @@
<%= form_with url: project_funding_request_path(@project),
method: :post,
data: { turbo: false },
html: { class: "ship-modal__form", data: { tier_maxes: Certification::FundingRequest::TIER_MAX_DOLLARS.to_json, stardust_per_dollar: Certification::FundingRequest::DISCOUNT_STARDUST_PER_DOLLAR } } do |form| %>
html: { class: "ship-modal__form", data: { tier_maxes: Certification::FundingRequest::TIER_MAX_DOLLARS.to_json, tier_discounts: Certification::FundingRequest.tier_discount_summary.to_json } } do |form| %>
<div class="ship-modal__panel funding-modal__panel">
<header class="funding-modal__header">
<%= image_tag "outpost.png", alt: "Outpost", class: "funding-modal__logo" %>
Expand Down Expand Up @@ -1053,7 +1053,7 @@
<p class="funding-modal__discount" role="status" hidden></p>

<p class="funding-modal__note">
Spend less than your tier and the rest becomes Stardust toward the Outpost Ticket.
Get your design approved to earn Stardust toward the Outpost Ticket — higher tiers cover more of it.
<%= link_to "How tiers work", guide_path("tiers"), class: "funding-modal__guide-link", target: "_blank", rel: "noopener" %>
</p>

Expand All @@ -1072,7 +1072,7 @@
function fundingValidate(form) {
if (!form) return;
var maxes = JSON.parse(form.dataset.tierMaxes || "{}");
var perDollar = parseInt(form.dataset.stardustPerDollar || "0", 10);
var discounts = JSON.parse(form.dataset.tierDiscounts || "{}");
var tier = form.querySelector('[name="complexity_tier"]:checked');
var amountEl = form.querySelector('[name="requested_amount"]');
var err = form.querySelector(".funding-request__error");
Expand All @@ -1089,12 +1089,11 @@
submit.disabled = over;

if (discountEl) {
if (max != null && !isNaN(amount) && amount >= 1 && amount <= max) {
var unused = max - amount;
var stardust = unused * perDollar;
discountEl.textContent = unused > 0
? "Leaving $" + unused + " unrequested earns ✦ " + stardust + " toward the Outpost Ticket."
: "Requesting your full tier, so no Outpost Ticket discount this time.";
var d = tier ? discounts[tier.value] : null;
if (d) {
discountEl.textContent = d.pct >= 100
? "Approved at " + d.code + " tier = a free Outpost Ticket (✦ " + d.sd + ", 100% off)."
: "Approved at " + d.code + " tier = " + d.pct + "% off the Outpost Ticket (✦ " + d.sd + ").";
discountEl.hidden = false;
} else {
discountEl.hidden = true;
Expand Down
2 changes: 1 addition & 1 deletion app/views/shop/items/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@
</div>
<% if @shop_item.is_a?(ShopItem::OutpostTicket) && current_user.outpost_discount_stardust.to_i > 0 %>
<div class="shop-order__summary-outpost">
<p><%= safe_join([ "You've earned ", stardust_icon, " ", current_user.outpost_discount_stardust.to_s, " toward the Outpost Ticket from unrequested funding." ]) %></p>
<p><%= safe_join([ "You've earned ", stardust_icon, " ", current_user.outpost_discount_stardust.to_s, " toward the Outpost Ticket from your funded hardware designs." ]) %></p>
<% if current_user.outpost_flight_stipend > 0 %>
<p><%= safe_join([ "That covers the ticket and leaves ", stardust_icon, " ", content_tag(:strong, current_user.outpost_flight_stipend.to_s), " toward a flight stipend." ]) %></p>
<% end %>
Expand Down
3 changes: 2 additions & 1 deletion db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@
{ slug: "hardware", title: "Hardware Shop", hub_title: "Hardware", position: 1 },
{ slug: "digital", title: "Digital Shop", hub_title: "Digital", position: 2 },
{ slug: "merch", title: "Merch Shop", hub_title: "Merch", position: 3 },
{ slug: "games", title: "Games Shop", hub_title: "Games", position: 4 }
{ slug: "games", title: "Games Shop", hub_title: "Games", position: 4 },
{ slug: "outpost", title: "Outpost Shop", hub_title: "Outpost", position: 5 }
].each do |attrs|
ShopCategory.find_or_create_by!(slug: attrs[:slug]) do |c|
c.title = attrs[:title]
Expand Down
9 changes: 5 additions & 4 deletions db/seeds/outpost_ticket.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

outpost_ticket = ShopItem::OutpostTicket.find_or_create_by!(name: "Outpost Ticket") do |item|
item.description = "Your ticket to the Outpost. Unlocked once you have a presentable hardware project."
item.long_description = "Earn this by building a hardware project worth showing off. Every dollar you leave on the table when requesting build funding knocks #{Certification::FundingRequest::DISCOUNT_STARDUST_PER_DOLLAR} Stardust off the price, and any overflow goes toward a flight stipend."
item.long_description = "Earn this by building a hardware project worth showing off. Getting your design funded discounts the ticket by your tier — 30% at B, 50% at A, and 100% at S and X — and any overflow goes toward a flight stipend."
item.ticket_cost = User::OUTPOST_TICKET_BASE
item.enabled = true
item.one_per_person_ever = true
Expand All @@ -15,6 +15,7 @@
)
end

hardware_category = ShopCategory.find_by(slug: "hardware")
outpost_ticket.shop_categories << hardware_category if hardware_category && !outpost_ticket.shop_categories.include?(hardware_category)
outpost_ticket.shop_sources << hq_source unless outpost_ticket.shop_sources.include?(hq_source)
outpost_category = ShopCategory.find_by(slug: "outpost")
# Assign (not append) so re-seeding moves the ticket out of any prior category.
outpost_ticket.shop_categories = [ outpost_category ] if outpost_category
outpost_ticket.shop_sources << hq_source unless outpost_ticket.shop_sources.include?(hq_source)
14 changes: 7 additions & 7 deletions test/models/certification/funding_request_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,30 +72,30 @@ def setup
fr.update!(reviewer: @reviewer, status: :approved)

assert_equal "build", @project.reload.hardware_stage
# tier 3 (S) max $180, approved $60 => $120 unused * 2 = 240
assert_equal 240, @owner.reload.outpost_discount_stardust
# tier 3 (S) => flat 100% discount on the 300✦ Outpost Ticket = 300
assert_equal 300, @owner.reload.outpost_discount_stardust
assert_equal Certification::FundingRequest::REVIEW_BOUNTY, fr.reload.stardust_earned
end

test "reviewer can approve for less than requested, increasing the discount" do
test "approving for less than requested still grants the full flat tier discount" do
fr = @project.certification_funding_requests.create!(
user: @owner, complexity_tier: 3, requested_amount_cents: 10_000, status: :pending
)
fr.update!(reviewer: @reviewer, status: :approved, approved_amount_dollars: 40)

# approved $40 of $180 (S) max => $140 unused * 2 = 280
assert_equal 280, @owner.reload.outpost_discount_stardust
# flat per-tier discount: the approved amount no longer affects it; tier 3 (S) = 300
assert_equal 300, @owner.reload.outpost_discount_stardust
end

test "discount accrual is idempotent across re-saves" do
fr = @project.certification_funding_requests.create!(
user: @owner, complexity_tier: 3, requested_amount_cents: 6_000, status: :pending
)
fr.update!(reviewer: @reviewer, status: :approved)
assert_equal 240, @owner.reload.outpost_discount_stardust
assert_equal 300, @owner.reload.outpost_discount_stardust

fr.update!(feedback: "nice work")
assert_equal 240, @owner.reload.outpost_discount_stardust
assert_equal 300, @owner.reload.outpost_discount_stardust
end

test "returned requests leave the project and discount untouched" do
Expand Down