Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/components/ContactUs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function ContactUs() {
return (<a href="https://www.harpersystems.dev/contact" target="_blank" className="underline">
Contact us
</a>);
}
31 changes: 15 additions & 16 deletions src/features/clusters/upsert/ClusterBilling.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { ContactUs } from '@/components/ContactUs';
import { Button } from '@/components/ui/button';
import { ResourcesPerInstance } from '@/features/clusters/upsert/components/ResourcesPerInstance';
import { PaymentMethodsDisplay } from '@/features/organization/billing/paymentMethod/PaymentMethodsDisplay';
import { getOrganizationQueryOptions } from '@/features/organization/queries/getOrganizationQuery';
import { PaymentMethodStatus } from '@/integrations/stripe/paymentMethodStatus';
import { SchemaPlan } from '@/lib/api.gen';
import { pluralize } from '@/lib/pluralize';
import { isPositive } from '@/lib/types/isPositive';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
import { useState } from 'react';
Expand Down Expand Up @@ -35,6 +38,7 @@ export function ClusterBilling({
const isEnterprise = organization?.type === 'ENTERPRISE';
const hasValidPaymentMethod = allowBypass || isEnterprise || billing?.paymentMethod?.status === PaymentMethodStatus.PASS;
const [replacingPaymentMethod, setReplacingPaymentMethod] = useState(false);
const expirationMonths = isPositive(selectedPlan?.planLimits?.expirationMonths) && selectedPlan.planLimits.expirationMonths < 1000 && selectedPlan.planLimits.expirationMonths;

const footer = (<>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end mt-3 max-w-xl">
Expand All @@ -60,9 +64,7 @@ export function ClusterBilling({
this page.
</li>
<li>Your account representative can work with you to sort out more precise details, and to help
accomplish your objectives with this
cluster. <a href="https://www.harpersystems.dev/contact" target="_blank" className="underline">Contact
us</a>, we are here to help.
accomplish your objectives with this cluster. <ContactUs />, we are here to help.
</li>
</ul>

Expand All @@ -81,33 +83,30 @@ export function ClusterBilling({
</>);
}

const andMightAutoRenew = selectedAutoRenew ? ', and your next auto renewal will be for all purchased blocks' : '';
const orPossiblyExpires = expirationMonths && `, or ${pluralize(expirationMonths, 'month', 'months')} elapse`;
const maybeNot = selectedAutoRenew ? ' NOT' : '';

return (<>
<ul className="list-disc ml-6 mb-6">
<li>You will be billed for this cluster today, and will receive a license for the block of usage you've
requested.
</li>
{clusterId && (
<li>If you scale up, you'll be charged for the additional blocks you've purchased
now{selectedAutoRenew ? ', and your next auto renewal will be for all purchased blocks' : ''}.
<li>If you scale up, you'll be charged for the additional blocks you've purchased now{andMightAutoRenew}.
</li>)}
{clusterId && (<li>If you remove a region, that usage block will not be used (because it is specific to that
region).</li>)}
<li>{selectedAutoRenew
? 'When that block is used up, or 3 months elapse, you will be automatically renewed.'
: 'When that block is used up, or 3 months elapse, you will NOT be automatically renewed.'}
{clusterId && (<li>If you remove a region, that region's usage block will not be used anymore (because it is
specific to that region).</li>)}
<li>When that block is used up{orPossiblyExpires}, you will{maybeNot} be automatically renewed.
</li>
<li>While refunds are not available, we’d be happy to assist you with... swag, depending on availability.
</li>
<li>We would love to work with you to sort out more precise details, and to help accomplish your objectives
with this
cluster. <a href="https://www.harpersystems.dev/contact" target="_blank" className="underline">Contact
us</a>, we are here to help.
with this cluster. <ContactUs />, we are here to help.
</li>
</ul>

{selectedPlan?.planLimits && selectedPlan.resourcesPerInstance && (
<ResourcesPerInstance planLimits={selectedPlan.planLimits} resourcesPerInstance={selectedPlan.resourcesPerInstance} />
)}
<ResourcesPerInstance planLimits={selectedPlan?.planLimits} resourcesPerInstance={selectedPlan?.resourcesPerInstance} />

<p className="text-muted-foreground text-sm mb-6">Payment method:</p>

Expand Down
8 changes: 3 additions & 5 deletions src/features/clusters/upsert/ClusterDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function ClusterDetails({
const isSelfManaged = selectedDeployment === 'Self-Hosted';

return (<>
<div className="grid grid-cols-3 gap-6 text-white md:grid-cols-6 overflow-auto max-h-[calc(100vh-theme(spacing.52))]">
<div className="grid grid-cols-3 gap-6 text-white md:grid-cols-6">
<FormField
control={form.control}
name="systemName"
Expand Down Expand Up @@ -242,11 +242,9 @@ export function ClusterDetails({
/>)
}

{selectedPlan?.planLimits && selectedPlan.resourcesPerInstance && (
<ResourcesPerInstance planLimits={selectedPlan.planLimits} resourcesPerInstance={selectedPlan.resourcesPerInstance} />
)}
<ResourcesPerInstance planLimits={selectedPlan?.planLimits} resourcesPerInstance={selectedPlan?.resourcesPerInstance} />
</div>
<DialogFooter className="mt-3">
<DialogFooter className="mt-3 mb-12">
<Button type="submit" variant="submit" className="rounded-full" disabled={isPending || !isDirty || !isValid}>
{totalPrice > 0 ? 'Confirm Payment Details' : clusterId ? 'Edit Cluster' : 'Create New Cluster'}
<ArrowRight />
Expand Down
165 changes: 93 additions & 72 deletions src/features/clusters/upsert/components/ResourcesPerInstance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,90 +8,110 @@ import { excludeFalsy } from '@/lib/arrays/excludeFalsy';
import { cn } from '@/lib/cn';
import { humanFileSize } from '@/lib/humanFileSize';
import { humanNumber } from '@/lib/humanNumber';
import { pluralize } from '@/lib/pluralize';
import { isPositive } from '@/lib/types/isPositive';
import { ArrowDownIcon, ArrowRightIcon } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';

export function ResourcesPerInstance({ planLimits, resourcesPerInstance }: {
readonly planLimits: SchemaPlanLimits,
readonly resourcesPerInstance: SchemaResourcesPerInstance
readonly planLimits: SchemaPlanLimits | undefined,
readonly resourcesPerInstance: SchemaResourcesPerInstance | undefined
}) {
const [toggled, setToggled] = useState(false);
const onUsageLimitsClick = useCallback(() => {
setToggled(!toggled);
}, [toggled, setToggled]);

const rows = useMemo(() => [
planLimits.totalReadCount && {
label: 'Total Reads',
value: `${humanNumber(planLimits.totalReadCount)} reads`,
},
planLimits.totalReadsBytes && {
label: 'Total Read Transfer',
value: `${humanFileSize(planLimits.totalReadsBytes)}`,
},
planLimits.readsPerMinuteCount && {
label: 'Read Rate',
value: `${humanNumber(planLimits.readsPerMinuteCount * 60)}/min`,
},
planLimits.readsPerMinuteBytes && {
label: 'Read Bandwidth',
value: `${humanFileSize(planLimits.readsPerMinuteBytes * 60)}/min`,
},
planLimits.totalWriteCount && {
label: 'Total Writes',
value: `${humanNumber(planLimits.totalWriteCount)} reads`,
},
planLimits.totalWritesBytes && {
label: 'Total Write Transfer',
value: `${humanFileSize(planLimits.totalWritesBytes)}`,
},
planLimits.writesPerMinuteCount && {
label: 'Write Rate',
value: `${humanNumber(planLimits.writesPerMinuteCount * 60)}/min`,
},
planLimits.writesPerMinuteBytes && {
label: 'Write Bandwidth',
value: `${humanFileSize(planLimits.writesPerMinuteBytes * 60)}/min`,
},
planLimits.totalRealTimeMessageDeliveries && {
label: 'Total Real-Time Messages',
value: `${humanNumber(planLimits.totalRealTimeMessageDeliveries)} messages`,
},
planLimits.totalRealTimeMessageDeliveryBytes && {
label: 'Total Real-Time Message Transfer',
value: `${humanFileSize(planLimits.totalRealTimeMessageDeliveryBytes)}`,
},
planLimits.realTimeMessageDeliveriesPerMinute && {
label: 'Real-Time Message Rate',
value: `${humanNumber(planLimits.realTimeMessageDeliveriesPerMinute * 60)}/min`,
},
planLimits.realTimeMessageDeliveryBytesPerMinute && {
label: 'Real-Time Message Bandwidth',
value: `${humanFileSize(planLimits.realTimeMessageDeliveryBytesPerMinute * 60)}/min`,
},
planLimits.tlsHandshakes && {
label: 'TLS Handshakes',
value: `${humanNumber(planLimits.tlsHandshakes * 60)}`,
},
planLimits.applicationComputeHours && {
label: 'Application Compute Hours',
value: `${humanNumber(planLimits.applicationComputeHours * 60)}`,
},
resourcesPerInstance?.storageGb && {
label: 'Storage',
value: `${humanFileSize(resourcesPerInstance.storageGb * 1000_000_000)}`,
},
{
label: 'Expiration',
value: '3 months',
},
].filter(excludeFalsy), [planLimits, resourcesPerInstance]);
const expirationMonths = isPositive(planLimits?.expirationMonths) && planLimits.expirationMonths < 1000 && planLimits.expirationMonths;

const rows = useMemo(() => {
if (!planLimits || !resourcesPerInstance) {
return [];
}
return [
isPositive(planLimits.totalReadCount) && {
label: 'Total Reads',
value: `${humanNumber(planLimits.totalReadCount)} reads`,
},
isPositive(planLimits.totalReadsBytes) && {
label: 'Total Read Transfer',
value: `${humanFileSize(planLimits.totalReadsBytes)}`,
},
isPositive(planLimits.readsPerMinuteCount) && {
label: 'Read Rate',
value: `${humanNumber(planLimits.readsPerMinuteCount * 60)}/min`,
},
isPositive(planLimits.readsPerMinuteBytes) && {
label: 'Read Bandwidth',
value: `${humanFileSize(planLimits.readsPerMinuteBytes * 60)}/min`,
},
isPositive(planLimits.totalWriteCount) && {
label: 'Total Writes',
value: `${humanNumber(planLimits.totalWriteCount)} reads`,
},
isPositive(planLimits.totalWritesBytes) && {
label: 'Total Write Transfer',
value: `${humanFileSize(planLimits.totalWritesBytes)}`,
},
isPositive(planLimits.writesPerMinuteCount) && {
label: 'Write Rate',
value: `${humanNumber(planLimits.writesPerMinuteCount * 60)}/min`,
},
isPositive(planLimits.writesPerMinuteBytes) && {
label: 'Write Bandwidth',
value: `${humanFileSize(planLimits.writesPerMinuteBytes * 60)}/min`,
},
isPositive(planLimits.totalRealTimeMessageDeliveries) && {
label: 'Total Real-Time Messages',
value: `${humanNumber(planLimits.totalRealTimeMessageDeliveries)} messages`,
},
isPositive(planLimits.totalRealTimeMessageDeliveryBytes) && {
label: 'Total Real-Time Message Transfer',
value: `${humanFileSize(planLimits.totalRealTimeMessageDeliveryBytes)}`,
},
isPositive(planLimits.realTimeMessageDeliveriesPerMinute) && {
label: 'Real-Time Message Rate',
value: `${humanNumber(planLimits.realTimeMessageDeliveriesPerMinute * 60)}/min`,
},
isPositive(planLimits.realTimeMessageDeliveryBytesPerMinute) && {
label: 'Real-Time Message Bandwidth',
value: `${humanFileSize(planLimits.realTimeMessageDeliveryBytesPerMinute * 60)}/min`,
},
isPositive(planLimits.tlsHandshakes) && {
label: 'TLS Handshakes',
value: `${humanNumber(planLimits.tlsHandshakes * 60)}`,
},
isPositive(planLimits.applicationComputeHours) && {
label: 'Application Compute Hours',
value: `${humanNumber(planLimits.applicationComputeHours * 60)}`,
},
isPositive(resourcesPerInstance?.storageGb) && {
label: 'Storage',
value: `${humanFileSize(resourcesPerInstance.storageGb * 1000_000_000)}`,
},
expirationMonths && {
label: 'Expiration',
value: pluralize(expirationMonths, 'month', 'months'),
},
].filter(excludeFalsy);
}, [expirationMonths, planLimits, resourcesPerInstance]);

if (!planLimits || !resourcesPerInstance) {
// The user hasn't selected a plan yet. so let's not show anything for the ResourcesPerInstance space yet.
return '';
}

if (!isPositive(planLimits.totalReadCount)) {
return 'This plan has no usage limits.';
}

return <FormItem className="col-span-3 md:col-span-6">
<FormLabel>
Purchasing usage block for {humanNumber(planLimits.readsPerMinuteCount!)} reads/min
&amp; {humanNumber(planLimits.totalReadCount)} total reads per region,
{humanNumber(planLimits.writesPerMinuteCount!)} writes/min &amp; {humanNumber(planLimits.totalWriteCount)} total writes, for 3 months.
Purchasing usage block for {isPositive(planLimits.readsPerMinuteCount) ? `${humanNumber(planLimits.readsPerMinuteCount)} reads/min & ` : ''}
{humanNumber(planLimits.totalReadCount)} total reads per {isPositive(planLimits.readsPerMinuteCount) ? 'region' : 'server'},<br className="hidden sm:block" />
{isPositive(planLimits.writesPerMinuteCount) ? ` ${humanNumber(planLimits.writesPerMinuteCount)} writes/min & ` : ' '}
{humanNumber(planLimits.totalWriteCount)} total writes{expirationMonths && `, for ${pluralize(expirationMonths, 'month', 'months')}`}.
<br className="block sm:hidden" />
<Button
type="button"
variant="link"
Expand All @@ -103,7 +123,8 @@ export function ResourcesPerInstance({ planLimits, resourcesPerInstance }: {
</FormLabel>
<FormControl>
<dl className={cn('divide-y divide-black overflow-hidden transition-[max-height] duration-200 ease-in', toggled ? 'max-h-fit' : 'max-h-0')}>
This plan licenses Harper for the usage limits below, for the price listed above. The usage license expires in three months or when any usage
This plan licenses Harper for the usage limits below, for the price listed above. The usage license
expires {expirationMonths && `in ${pluralize(expirationMonths, 'month', 'months')} or `}when any usage
limit is reached. Usage blocks can be purchased as they are consumed.
{rows.map((row, index) =>
<div key={row.label} className={cn('px-4 py-1 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-3', index % 2 === 0 && 'bg-gray-700')}>
Expand Down
8 changes: 3 additions & 5 deletions src/features/organization/billing/invoices/Invoices.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ContactUs } from '@/components/ContactUs';
import { Loading } from '@/components/Loading';
import { SimpleBrowseDataTable } from '@/components/SimpleBrowseDataTable';
import { Button } from '@/components/ui/button';
Expand Down Expand Up @@ -26,9 +27,7 @@ export function Invoices() {
return (
<span>
You are part of an enterprise organization! We don&rsquo;t currently show your invoices on this page.
Want to explore your solution with Harper
more? <a href="https://www.harpersystems.dev/contact" target="_blank" className="underline">Contact
us</a>, we would love to talk!
Want to explore your solution with Harper more? <ContactUs />, we would love to talk!
</span>
);
}
Expand All @@ -39,8 +38,7 @@ export function Invoices() {
return (
<span>
Your invoices will be shown here once one is available! Want to explore your solution with Harper
more? <a href="https://www.harpersystems.dev/contact" target="_blank" className="underline">Contact
us</a>, we would love to talk!
more? <ContactUs />, we would love to talk!
</span>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ContactUs } from '@/components/ContactUs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { AddNewPaymentMethod } from '@/features/organization/billing/paymentMethod/AddNewPaymentMethod';
Expand Down Expand Up @@ -48,9 +49,7 @@ export function PaymentMethodsDisplay({
return (
<span>
You are part of an enterprise organization! We don&rsquo;t currently show your payment methods on this
page. Want to explore your solution with Harper
more? <a href="https://www.harpersystems.dev/contact" target="_blank" className="underline">Contact
us</a>, we would love to talk!
page. Want to explore your solution with Harper more? <ContactUs />, we would love to talk!
</span>
);
}
Expand Down
51 changes: 51 additions & 0 deletions src/lib/types/isPositive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, expectTypeOf, it } from 'vitest';
import { isPositive } from './isPositive';

describe('isPositive', () => {
it('returns false for undefined', () => {
const value: number | undefined = undefined;
expect(isPositive(value)).toBe(false);
});

it('returns false for 0', () => {
expect(isPositive(0)).toBe(false);
});

it('returns false for negative numbers', () => {
expect(isPositive(-1)).toBe(false);
expect(isPositive(-0.0001)).toBe(false);
});

it('returns true for positive integers', () => {
expect(isPositive(1)).toBe(true);
expect(isPositive(42)).toBe(true);
});

it('returns true for positive floats', () => {
expect(isPositive(0.1)).toBe(true);
expect(isPositive(Math.PI)).toBe(true);
});

it('returns true for very small positive epsilon', () => {
expect(isPositive(Number.EPSILON)).toBe(true);
});

it('handles special numeric values as expected', () => {
expect(isPositive(NaN)).toBe(false);
expect(isPositive(Infinity)).toBe(true);
});

it('acts as a type guard to narrow number | undefined to number', () => {
const maybe: number | undefined = Math.random() > 0.5 ? 5 : undefined;

if (isPositive(maybe)) {
// Inside this branch, `maybe` should be narrowed to `number`.
expectTypeOf(maybe).toEqualTypeOf<number>();
// At runtime we can also use it as a number safely.
expect(maybe > 0).toBe(true);
} else {
// Here, `maybe` is `number | undefined`, but not a positive number.
expect(maybe === undefined || maybe <= 0).toBe(true);
}
});
});
3 changes: 3 additions & 0 deletions src/lib/types/isPositive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isPositive(value: number | undefined): value is number {
return value !== undefined && value > 0;
}