Skip to main content

max / makenotwork

Carry the PWYW paywall amount in dollars, the unit its label promises The box was labelled dollars and named amount_cents, so a buyer who typed 100 was charged one dollar. The wire now carries dollars and the handler does the one conversion through pricing::parse_dollars_to_cents, with the minimum and the USD unit shown on the field.
Author: Max Johnson <me@maxj.phd> · 2026-08-31 11:38 UTC
Signed with PGP, not checked
Commit: 598c7035636b523e9eab4d4b0c6ed7cedc2cf0e9
Parent: ed28fb7
4 files changed, +83 insertions, -8 deletions
@@ -59,8 +59,10 @@
59 59 {% if session_user.is_some() %}
60 60 <form method="POST" action="/stripe/checkout/project/{{ project.id }}">
61 61 <div class="form-group form-group--centered-narrow">
62 - <label for="amount">Your price ($)</label>
63 - <input type="number" name="amount_cents" id="amount" min="0" step="1" placeholder="0">
62 + <label for="amount">Your price</label>
63 + <input type="number" name="amount_dollars" id="amount" min="{{ pwyw_min_dollars }}" step="0.01" placeholder="0.00"
64 + aria-describedby="amount-unit">
65 + <span class="form-unit" id="amount-unit">USD</span>
64 66 </div>
65 67 <button type="submit" class="btn-primary" data-loading-text="Redirecting to Stripe...">Purchase Access</button>
66 68 </form>
@@ -235,7 +235,7 @@
235 235 /// Step 1 partial: account creation (back-nav reload, and the HTMX validation
236 236 /// re-render). Carries preserved input + error so a failed HTMX submit swaps the
237 237 /// form back with the typed username/email intact and the bad field flagged,
238 - /// instead of replacing the whole step with a bare error line (UX-S1, Run #23).
238 + /// instead of replacing the whole step with a bare error line.
239 239 #[derive(Template)]
240 240 #[template(path = "wizards/steps/join/account.html")]
241 241 pub struct WizardJoinAccountTemplate {
@@ -422,6 +422,9 @@
422 422 pub price_display: String,
423 423 /// What kind of checkout flow is needed.
424 424 pub checkout_type: crate::pricing::CheckoutType,
425 + /// The pay-what-you-want minimum as a plain decimal ("0.00", "9.99"), for
426 + /// the `min` on the dollars input. Zero for every other checkout type.
427 + pub pwyw_min_dollars: String,
425 428 /// Available subscription tiers (for subscription-model projects).
426 429 pub subscription_tiers: Vec<SubscriptionTier>,
427 430 /// Base URL for OG meta tags.
@@ -774,8 +777,8 @@
774 777 pub session_user: Option<SessionUser>,
775 778 /// The feed itself, described rather than spelled: `crate::quasi::feeds`
776 779 /// builds the table and its numbered strip, and this template is the shell
777 - /// around it. The rows and the paging used to be seven fields here and the
778 - /// same seven on `LibraryFeedTabTemplate`, drawing the same table twice.
780 + /// around it. `LibraryFeedTabTemplate` draws the same table from the same
781 + /// description.
779 782 pub body: String,
780 783 }
781 784
@@ -22,8 +22,26 @@
22 22 pub(in crate::routes::stripe) struct ProjectCheckoutForm {
23 23 #[serde(default)]
24 24 share_contact: bool,
25 - /// PWYW: buyer-chosen amount in cents.
26 - amount_cents: Option<i32>,
25 + /// PWYW: buyer-chosen amount in dollars, exactly as typed into the paywall
26 + /// box. The wire unit is dollars because the label the buyer reads says
27 + /// dollars; the conversion to cents happens once, in
28 + /// [`ProjectCheckoutForm::amount_cents`], right before the amount is
29 + /// validated and charged.
30 + amount_dollars: Option<String>,
31 + }
32 +
33 + impl ProjectCheckoutForm {
34 + /// The buyer-chosen PWYW amount in cents, or `None` when the field was not
35 + /// submitted at all.
36 + ///
37 + /// Goes through `pricing::parse_dollars_to_cents`, the one canonical
38 + /// dollars-to-cents conversion, so "5" is 500 cents rather than 5.
39 + fn amount_cents(&self) -> Result<Option<i32>> {
40 + self.amount_dollars
41 + .as_deref()
42 + .map(|raw| pricing::parse_dollars_to_cents("Amount", Some(raw)))
43 + .transpose()
44 + }
27 45 }
28 46
29 47 /// POST /stripe/checkout/project/{project_id}: Purchase project-level access.
@@ -80,7 +98,7 @@
80 98
81 99 // Determine price
82 100 let base_price_cents = if project_pricing.checkout_type() == CheckoutType::PayWhatYouWant {
83 - let amount = form.amount_cents.ok_or_else(|| {
101 + let amount = form.amount_cents()?.ok_or_else(|| {
84 102 AppError::BadRequest("Amount is required for pay-what-you-want projects".to_string())
85 103 })?;
86 104 project_pricing
@@ -198,3 +216,52 @@
198 216
199 217 Ok(Redirect::to(&checkout_url).into_response())
200 218 }
219 +
220 + #[cfg(test)]
221 + mod tests {
222 + //! The unit conversion on the PWYW paywall. The box is labelled dollars and
223 + //! the wire carries dollars, so the handler owns the one multiplication that
224 + //! turns what the buyer typed into what Stripe charges. Cents on the wire
225 + //! under a dollars label charges a buyer who types 100 a single dollar.
226 +
227 + use super::*;
228 +
229 + fn form(amount: Option<&str>) -> ProjectCheckoutForm {
230 + ProjectCheckoutForm {
231 + share_contact: false,
232 + amount_dollars: amount.map(str::to_string),
233 + }
234 + }
235 +
236 + #[test]
237 + fn a_whole_dollar_figure_becomes_cents() {
238 + assert_eq!(form(Some("100")).amount_cents().unwrap(), Some(10_000));
239 + assert_eq!(form(Some("5")).amount_cents().unwrap(), Some(500));
240 + assert_eq!(form(Some("1")).amount_cents().unwrap(), Some(100));
241 + }
242 +
243 + #[test]
244 + fn cents_typed_after_the_point_survive() {
245 + assert_eq!(form(Some("9.99")).amount_cents().unwrap(), Some(999));
246 + assert_eq!(form(Some("0.50")).amount_cents().unwrap(), Some(50));
247 + assert_eq!(form(Some("1250.05")).amount_cents().unwrap(), Some(125_005));
248 + }
249 +
250 + #[test]
251 + fn a_missing_field_is_distinct_from_an_empty_one() {
252 + // Absent: the caller raises "Amount is required". Empty: zero, which the
253 + // pricing model accepts only when the minimum is $0.
254 + assert_eq!(form(None).amount_cents().unwrap(), None);
255 + assert_eq!(form(Some("")).amount_cents().unwrap(), Some(0));
256 + }
257 +
258 + #[test]
259 + fn junk_is_refused_rather_than_charged() {
260 + for raw in ["abc", "-5", "NaN", "inf"] {
261 + assert!(
262 + form(Some(raw)).amount_cents().is_err(),
263 + "{raw} must not reach the charge"
264 + );
265 + }
266 + }
267 + }
@@ -113,6 +113,9 @@
113 113 creator_username: db_user.username.to_string(),
114 114 price_display: project_pricing.price_display(db_user.settlement_currency),
115 115 checkout_type: project_pricing.checkout_type(),
116 + pwyw_min_dollars: crate::formatting::format_dollars_plain(
117 + project_pricing.minimum_cents().unwrap_or(0),
118 + ),
116 119 subscription_tiers,
117 120 host_url: config.host_url.clone(),
118 121 }