Round prorations to the nearest hour

This commit is contained in:
Jon Staab
2026-06-01 14:43:40 -07:00
parent 572f772ed1
commit 76fbee6be1
2 changed files with 39 additions and 21 deletions
+6 -2
View File
@@ -574,13 +574,17 @@ impl BillingPeriod {
} }
/// Fraction of this period still unused at `at`, in `[0.0, 1.0]`, for /// Fraction of this period still unused at `at`, in `[0.0, 1.0]`, for
/// prorating a mid-period charge or credit. /// prorating a mid-period charge or credit. The remaining time is rounded to
/// the nearest hour so proration tracks whole hours rather than exact seconds.
fn fraction_remaining(&self, at: i64) -> f64 { fn fraction_remaining(&self, at: i64) -> f64 {
const HOUR: i64 = 60 * 60;
let len = (self.end - self.start) as f64; let len = (self.end - self.start) as f64;
if len <= 0.0 { if len <= 0.0 {
return 1.0; return 1.0;
} }
(((self.end - at) as f64) / len).clamp(0.0, 1.0) let remaining = ((self.end - at) + HOUR / 2) / HOUR * HOUR;
(remaining as f64 / len).clamp(0.0, 1.0)
} }
/// Prorate a minor-unit `amount` by the fraction of this period remaining /// Prorate a minor-unit `amount` by the fraction of this period remaining
+33 -19
View File
@@ -14,35 +14,49 @@ export default function RelayNew() {
const navigate = useNavigate() const navigate = useNavigate()
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>() const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false) const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
const [creatingPaid, setCreatingPaid] = createSignal(false)
let createdRelayId = "" let createdRelayId = ""
// While this flow's inline modals are open, suppress the shared banner's // Suppress the shared banner's overlapping pay/setup prompts (it still surfaces
// overlapping pay/setup prompts (it still surfaces churn / method errors). // churn / method errors) for the whole paid-creation flow: `creatingPaid` covers
createEffect(() => setBillingFlowActive(Boolean(pendingInvoice()) || paymentSetupOpen())) // the async gap between create and a modal opening — without it the invoice that
// refetchBilling surfaces flashes the banner before the modal appears — and
// pendingInvoice/paymentSetupOpen cover the time the modals are open.
createEffect(() => setBillingFlowActive(creatingPaid() || Boolean(pendingInvoice()) || paymentSetupOpen()))
onCleanup(() => setBillingFlowActive(false)) onCleanup(() => setBillingFlowActive(false))
async function handleSubmit(values: RelayFormValues) { async function handleSubmit(values: RelayFormValues) {
const relay = await createRelayForActiveTenant(values) // Paid plans materialize an open invoice on create; mark the flow active
createdRelayId = relay.id // before refetchBilling surfaces it so the banner stays suppressed across the
void refetchBilling() // whole async gap, not just once a modal opens. The finally releases it on
// every exit (error, free plan, or after a modal has taken over suppression).
const paid = values.plan_id !== "free"
if (paid) setCreatingPaid(true)
try {
const relay = await createRelayForActiveTenant(values)
createdRelayId = relay.id
void refetchBilling()
if (values.plan_id !== "free") { if (paid) {
const needsSetup = await tenantNeedsPaymentSetup() const needsSetup = await tenantNeedsPaymentSetup()
if (needsSetup) { if (needsSetup) {
// Materialize the invoice for this change (no collection, no DM) so we // Materialize the invoice for this change (no collection, no DM) so we
// can prompt the tenant to pay it directly. listTenantInvoices reconciles // can prompt the tenant to pay it directly. listTenantInvoices reconciles
// first, so a just-created invoice is visible here. // first, so a just-created invoice is visible here.
const invoice = await getLatestOpenInvoice() const invoice = await getLatestOpenInvoice()
if (invoice) { if (invoice) {
setPendingInvoice(invoice) setPendingInvoice(invoice)
return
}
setPaymentSetupOpen(true)
return return
} }
setPaymentSetupOpen(true)
return
} }
}
navigate(`/relays/${relay.id}`) navigate(`/relays/${relay.id}`)
} finally {
setCreatingPaid(false)
}
} }
function handleInvoiceClose() { function handleInvoiceClose() {