cleaned up stripe config

This commit is contained in:
Corey Blais
2026-04-30 21:54:13 -04:00
parent 646f895ed6
commit cd7c4383d0
9 changed files with 384 additions and 73 deletions
+159 -52
View File
@@ -17,7 +17,7 @@ type Bird = {
id: string;
workspaceId?: number;
name: string;
tagId: string;
tagId: string | null;
species: string;
gender: BirdGender;
dateOfBirth: string | null;
@@ -231,6 +231,11 @@ type AuthNotice = {
previewUrl?: string | null;
};
type BillingNotice = {
kind: 'success' | 'info' | 'error';
message: string;
};
type BulkWeightRowState = {
weightGrams: string;
};
@@ -310,7 +315,7 @@ const emptyBirdForm: BirdFormState = {
const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({
memorializedOn: new Date().toISOString().slice(0, 10),
memorialNote: '',
notifyOnMemorialDay: true,
notifyOnMemorialDay: false,
});
const emptyWorkspaceForm: WorkspaceFormState = {
@@ -408,7 +413,7 @@ const sortBirdsByName = (nextBirds: Bird[]) => [...nextBirds].sort((left, right)
const toBirdForm = (bird: Bird): BirdFormState => ({
name: bird.name,
tagId: bird.tagId,
tagId: bird.tagId ?? '',
species: bird.species,
gender: bird.gender,
dateOfBirth: bird.dateOfBirth ?? '',
@@ -1072,6 +1077,7 @@ function App() {
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
const [authForm, setAuthForm] = useState<AuthFormState>(emptyAuthForm);
const [authNotice, setAuthNotice] = useState<AuthNotice | null>(null);
const [billingNotice, setBillingNotice] = useState<BillingNotice | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [authSubmitting, setAuthSubmitting] = useState(false);
const [lostBirdReportForm, setLostBirdReportForm] = useState<LostBirdReportFormState>(emptyLostBirdReportForm);
@@ -1157,6 +1163,7 @@ function App() {
} | null>(null);
const [deletingBird, setDeletingBird] = useState(false);
const [memorializingBird, setMemorializingBird] = useState(false);
const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState('');
const [editingVetVisitId, setEditingVetVisitId] = useState('');
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
const [editingMedicationId, setEditingMedicationId] = useState('');
@@ -1572,6 +1579,7 @@ function App() {
setAuthSession(session);
setAuthProviders(session.providers);
setAuthNotice(null);
setBillingNotice(null);
setNewIntegrationTokenSecret('');
setWorkspace(session.activeWorkspace);
setActiveMembership({
@@ -1616,6 +1624,34 @@ function App() {
setIntegrationTokenForm(emptyIntegrationTokenForm);
setNewIntegrationTokenSecret('');
setAuthNotice(null);
setBillingNotice(null);
};
const refreshAuthSession = async (token: string) => {
const response = await apiFetch('/auth/session', token);
if (!response.ok) {
if (response.status === 401) {
clearAppSession();
}
throw new Error(await readErrorMessage(response, 'Unable to refresh your billing status.'));
}
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
if (!data.session) {
throw new Error('Unable to refresh your billing status.');
}
const nextToken = data.token || token;
persistSessionToken(nextToken);
applySession(data.session, nextToken);
return {
session: data.session,
token: nextToken,
};
};
useEffect(() => {
@@ -1646,30 +1682,61 @@ function App() {
const url = new URL(window.location.href);
const callbackToken = url.searchParams.get('auth_token') ?? '';
const billingState = url.searchParams.get('billing');
const token = callbackToken || readStoredSessionToken();
if (callbackToken) {
persistSessionToken(callbackToken);
url.searchParams.delete('auth_token');
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
}
if (!token) {
return;
}
const response = await apiFetch('/auth/session', token);
const { session, token: sessionToken } = await refreshAuthSession(token);
if (!response.ok) {
clearAppSession();
return;
if (billingState === 'success' || billingState === 'portal') {
try {
const syncResponse = await apiFetch('/billing/sync', sessionToken, { method: 'POST' });
if (!syncResponse.ok) {
throw new Error(await readErrorMessage(syncResponse, 'Returned from Stripe, but billing could not be refreshed yet.'));
}
const { session: refreshedSession } = await refreshAuthSession(sessionToken);
const syncedWorkspace = refreshedSession.activeWorkspace;
const planName = formatBillingPlanName(syncedWorkspace.billingPlan);
const intervalName = formatBillingIntervalName(syncedWorkspace.billingInterval);
setBillingNotice({
kind: 'success',
message:
billingState === 'success'
? `Stripe checkout completed. Billing is now ${planName} on ${intervalName}.`
: `Stripe billing changes synced. Current plan: ${planName} on ${intervalName}.`,
});
} catch (billingSyncError) {
setBillingNotice({
kind: 'info',
message:
billingSyncError instanceof Error
? billingSyncError.message
: 'Returned from Stripe. Billing changes may still be syncing.',
});
}
} else if (billingState === 'cancelled') {
setBillingNotice({
kind: 'info',
message: 'Stripe checkout was cancelled. No billing changes were applied.',
});
} else {
setBillingNotice(null);
}
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
if (data.session && (data.token || token)) {
persistSessionToken(data.token || token);
applySession(data.session, data.token || token);
if (session && (callbackToken || billingState)) {
url.searchParams.delete('billing');
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
setError('');
}
} catch (loadError) {
@@ -2931,6 +2998,38 @@ function App() {
}
};
const handleMemorialReminderPreferenceChange = async (bird: Bird, notifyOnMemorialDay: boolean) => {
if (savingMemorialReminderBirdId) {
return;
}
setSavingMemorialReminderBirdId(bird.id);
setError('');
try {
const response = await apiFetch(`/birds/${bird.id}/memorial-reminders`, authToken, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notifyOnMemorialDay }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to update memorial reminder setting.'));
}
const data = await readJsonSafely<{ bird: Bird }>(response);
if (!data?.bird) {
throw new Error('Unable to update memorial reminder setting.');
}
setMemorializedBirds((current) => current.map((currentBird) => (currentBird.id === data.bird.id ? data.bird : currentBird)));
} catch (preferenceError) {
setError(preferenceError instanceof Error ? preferenceError.message : 'Unable to update memorial reminder setting.');
} finally {
setSavingMemorialReminderBirdId('');
}
};
const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (transferringBird) {
@@ -3172,6 +3271,7 @@ function App() {
}
setError('');
setBillingNotice(null);
setBillingRedirecting(true);
setSavingWorkspace(true);
@@ -3210,6 +3310,7 @@ function App() {
}
setError('');
setBillingNotice(null);
setBillingRedirecting(true);
try {
@@ -4104,7 +4205,7 @@ function App() {
</span>
</h3>
<p className="muted">
{selectedBird.species} • Band {selectedBird.tagId}
{selectedBird.species} • {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'}
</p>
<p className="muted">Added {formatDate(selectedBird.createdAt.slice(0, 10))}</p>
</div>
@@ -4117,7 +4218,7 @@ function App() {
</article>
<article className="detail-card">
<span>Band ID</span>
<strong>{selectedBird.tagId}</strong>
<strong>{selectedBird.tagId || 'Not recorded'}</strong>
</article>
<article className="detail-card">
<span>Hatch Day</span>
@@ -4489,6 +4590,7 @@ function App() {
</div>
{workspace?.workspaceType !== 'rescue' ? (
<div className="form-panel">
{billingNotice ? <p className={billingNotice.kind === 'error' ? 'error-banner' : 'success-banner'}>{billingNotice.message}</p> : null}
<label>
Household plan
<select
@@ -4964,32 +5066,6 @@ function App() {
))}
</div>
{memorializedBirds.length ? (
<section className="settings-subsection settings-nested-card">
<div className="panel-header">
<div>
<p className="eyebrow">Memorials</p>
<h3>Memorialized birds</h3>
<p className="muted">
These profiles are read-only, hidden from the standard flock view, and excluded from household plan bird counts.
</p>
</div>
</div>
<div className="recent-list">
{memorializedBirds.map((bird) => (
<article className="vet-visit-card" key={bird.id}>
<strong>{bird.name}</strong>
<small>
{bird.species} Memorialized {formatDate(bird.memorializedOn)}
</small>
{bird.notifyOnMemorialDay ? <small>Memorial day reminders enabled.</small> : <small>Memorial day reminders off.</small>}
{bird.memorialNote ? <p className="muted">{bird.memorialNote}</p> : null}
</article>
))}
</div>
</section>
) : null}
<form className="form-panel settings-nested-stack" onSubmit={handleBirdSubmit}>
<section className="settings-nested-card">
<div className="settings-nested-header">
@@ -5003,7 +5079,11 @@ function App() {
</label>
<label>
Band ID
<input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required />
<input
value={birdForm.tagId}
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
placeholder="Optional if unknown"
/>
</label>
<label className="species-picker-field wide-field">
Species
@@ -5398,15 +5478,6 @@ function App() {
required
/>
</label>
<label className="toggle-card">
<span>Send memorial day reminders</span>
<input
type="checkbox"
checked={memorializeBirdForm.notifyOnMemorialDay}
onChange={(event) => setMemorializeBirdForm({ ...memorializeBirdForm, notifyOnMemorialDay: event.target.checked })}
/>
<small className="muted">Send an annual remembrance notification using the same delivery workflow as Hatch Day reminders.</small>
</label>
<label className="wide-field">
Memorial note
<textarea
@@ -5433,6 +5504,42 @@ function App() {
</div>
</section>
) : null}
{memorializedBirds.length ? (
<section className="settings-subsection settings-nested-card">
<div className="panel-header">
<div>
<p className="eyebrow">Memorials</p>
<h3>Memorialized birds</h3>
<p className="muted">
These profiles are read-only, hidden from the standard flock view, and excluded from household plan bird counts.
</p>
</div>
</div>
<div className="recent-list">
{memorializedBirds.map((bird) => (
<article className="vet-visit-card" key={bird.id}>
<strong>{bird.name}</strong>
<small>
{bird.species} Memorialized {formatDate(bird.memorializedOn)}
</small>
{bird.memorialNote ? <p className="muted">{bird.memorialNote}</p> : null}
<label className="toggle-card">
<span>Send memorial reminders</span>
<input
type="checkbox"
checked={bird.notifyOnMemorialDay}
disabled={savingMemorialReminderBirdId === bird.id || activeMembership?.role !== 'owner'}
onChange={(event) => handleMemorialReminderPreferenceChange(bird, event.target.checked)}
/>
<small className="muted">Send an annual rememberance notificaiton.</small>
</label>
{activeMembership?.role !== 'owner' ? <small>Only flock owners can change memorial reminder settings.</small> : null}
</article>
))}
</div>
</section>
) : null}
</>
) : null}
</article>
@@ -5475,7 +5582,7 @@ function App() {
<option value="">Select a bird from this flock</option>
{birds.map((bird) => (
<option key={bird.id} value={bird.id}>
{bird.name} • {bird.species} • Band {bird.tagId}
{bird.name} • {bird.species} • {bird.tagId ? `Band ${bird.tagId}` : 'Band ID not recorded'}
</option>
))}
</select>