Commit Graph
362 Commits
Author SHA1 Message Date
yusufipek 1c24336b6a fix(billing): serialize reconciliation and record accepted cancellations 2026-09-08 16:32:11 +03:00
yusufipek 57061b5a5d fix(billing): preserve entitlements and bound cancellation cleanup
Integrate the latest cancellation-reason flow from master. Keep paid periods and independent trials intact, stop collection without erasing historical or mixed receivables, and make scheduled and partial cancellations recoverable. Add regression coverage for invoice boundaries, entitlement expiry, cancellation selection and concurrent reason writes.
2026-09-08 15:49:37 +03:00
yusufipek 2c890314c1 fix(billing): show paid cancellation dates instead of leftover trial 2026-09-08 15:17:19 +03:00
Yusuf İpek 36b2e5c905 Merge pull request #79 from yusufipk/claude/landing-page-design-refresh-c78363
feat(landing): refresh product-focused landing page
2026-09-08 15:08:33 +03:00
yusufipek a8607e8254 feat(landing): refresh product-focused landing page 2026-09-08 15:04:25 +03:00
Yusuf İpek 53c7899659 Merge pull request #78 from yusufipk/claude/lifecycle-messages-cancellation-a0142a
feat(billing): cancel in-app with a one-question reason
2026-09-08 14:21:18 +03:00
Yusuf İpek 6ad22508fe test(api): classify the billing cancel route in the auth matrix 2026-09-08 14:12:46 +03:00
yusufipek c0809e23bd test(billing): cover the Stripe field locations this change depends on
Every subscription fixture in the suite carries current_period_end at the top
level, which is the location the pinned API version no longer uses. So the item
level read, the reason this code exists, had no test at all and every other case
passed through the legacy fallback instead.

Covers both locations for the period and for the invoice's subscription link,
the null case the webhook relies on to leave a one-off invoice alone, and the
retry-window bound through the payload shape production actually sends.
2026-09-08 14:07:48 +03:00
Yusuf İpek 7aeda83eb6 feat(billing): cancel in-app with a one-question reason
Add a "Cancel subscription" button beside "Manage Subscription" in Settings.
It opens a dialog with one optional question (five answers, no default, a
note box under the two that want detail), then schedules the Stripe
subscription to end at the close of the current period without a trip to
the portal. The answer is stored in a new subscription_cancellations table
and shown, with an all-time tally, on the admin dashboard; the category is
also mirrored onto Stripe's cancellation feedback, the free text stays local.

The cancel route claims the local cancel flag with a conditional update
before calling Stripe, so two racing requests cannot both write a reason
row, and hands the claim back when Stripe refuses. A subscription Stripe no
longer knows answers 409 with a pointer to the portal instead of a 500. The
route carries an account-keyed rate limit on top of the shared IP one.

Two fixes found on the way: the pinned Stripe API version reports
current_period_end on the subscription item rather than the subscription, so
the sync stored null for every period end; a shared helper now reads the item
first. And the RadioGroup styles targeted a data-checked attribute radix
never writes, so the checked state was invisible in the light theme.
2026-09-08 14:05:16 +03:00
yusufipek d5cb288719 test(api): register the billing cancel route in the auth matrix
The api suite enumerates every route module under app/api and requires each
one to be classified as session-guarded or deliberately public. The new cancel
route was neither, so the suite failed on an unclassified module and on the
module count. It takes the same shape as the other billing routes: a session
plus a same-origin header.
2026-09-08 13:58:54 +03:00
yusufipek 85855a6d52 fix(billing): close the gaps the code and security reviews found
Follow-up on the same change, from a high-effort code review and security
review run over the diff.

Access gate:
- Scope both period-end guards to the period-end branch of hasBillingAccess
  instead of the top of the function. A cutoff is only ever cleared by a Stripe
  sync, so checking it first meant a stale one from a lapsed subscription
  outranked a freshly started cardless trial: the account burned its
  once-per-account trial and got nothing. buildBillingAccessWhereInput mirrors
  the same shape.
- Refuse a period end carried by an INCOMPLETE or INCOMPLETE_EXPIRED
  subscription, the rejection isPaidTier already makes. The cutoff is
  deliberately left null while a trial is live, so a trial user who abandoned a
  checkout kept the failed subscription's period once the trial ran out.
- Apply the cutoff in isPaidTier too, so it cannot say "paid" for a period
  where hasBillingAccess says access is over. That split left a locked-out
  account with no banner explaining it and able to create workspaces it could
  not then see. Both callers now select the field.

Lifecycle:
- Cancel through syncStripeCustomerSubscriptions rather than writing the single
  cancelled subscription, so a customer holding a second live subscription is
  not locked out of an account they are still being billed for.
- Ignore invoice events with no subscription. A one-off invoice against a
  customer record left by an abandoned checkout was marking the account
  canceled and booking a churn event for a subscription that never existed.
- Fall back to a window measured from now when a subscription behind on payment
  reports no period start, rather than falling through to "access ended", which
  locked out the customer that branch exists to keep in.
- Let a paused subscription run to its period end; it was being ended at once.
- Collapse BLOCKING_STRIPE_STATUSES into LIVE_STRIPE_STATUSES and include
  incomplete. The two sets were identical, which offered a Cancel button that
  always returned "No subscription to cancel" and left the Stripe-side checkout
  guard weaker than the mirror check it backs up.

UI and ops:
- cancelIsImmediate from the API, so the confirmation says what will actually
  happen to an incomplete subscription instead of promising the period end.
- The access banner reads "ended on" once the date has passed.
- The resync script selects the way the write path selects, over the customer's
  whole set. Filtering to live subscriptions first made the dry run disagree
  with the real run and skipped canceled and incomplete customers entirely,
  who are exactly the stale mirrors the script exists for.

Three existing tests asserted the behaviour this fixes: that a canceled
subscription keeps access to its reported period end, and that the cutoff is
ignored while that period runs. Both rest on the premise that a future period
end means a paid period, which is what is not true. They now assert the bound,
alongside new cases for the retry window, the trial-versus-stale-cutoff
ordering, and a never-paid period.
2026-09-08 13:51:59 +03:00
yusufipek fe1faeced4 fix(billing): pin the Stripe API version and stop unpaid periods granting access
The Stripe client was built without an apiVersion, so the SDK followed whatever
version it shipped with. Two fields moved in the Basil API version: the billing
period went from the subscription onto its items, and the invoice link to its
subscription went under parent.subscription_details. Both reads returned
undefined without failing, which left stripeCurrentPeriodEnd null for every
subscriber and left the app with no invoice handling at all. A customer whose
card failed saw nothing about the invoice that was still retrying, and a
cancellation did nothing to stop those retries.

- Pin the API version, with `satisfies` so an SDK bump is a compile error here
  before it is a null read in production.
- Read the period off subscription items and the subscription off invoice
  parents, keeping the legacy fields as a fallback for older payloads.
- Handle invoice.paid, invoice.payment_failed, invoice.voided and
  invoice.marked_uncollectible through the existing customer-wide resync, so
  the mirror reflects payment health during dunning rather than after it.
- Add an in-app cancellation route: at period end when the subscription is
  paid, immediately plus voiding the open invoices when it is not, because
  cancelling alone does not stop collection on an invoice already issued.
- Ask Stripe, not just the local mirror, before opening checkout.
- Show the open invoice, the retry date and a payment-method-update shortcut in
  settings, and put a confirmation in front of cancellation.

Access no longer rests on the reported period alone. Stripe advances the period
when it issues the renewal invoice, paid or not, and the period survives
cancellation, so once the period field started being read correctly that check
would have handed a full free month to anyone whose renewal failed, and the new
cancel route would have let them void the invoice and keep the month. Access now
follows the subscription status, billingAccessEndedAt is enforced as a hard
cutoff in both hasBillingAccess and the query that mirrors it, and a subscription
behind on payment keeps access for Stripe's retry window rather than for the
period it never paid for.
2026-09-08 13:30:42 +03:00
Yusuf İpek d5d2f0535e Merge pull request #75 from yusufipk/claude/openframe-mouse-overlay-bug-66603b
fix(player): re-arm the cursor idle timer when playback changes
2026-09-08 13:23:44 +03:00
Yusuf İpek 42b974e422 Merge pull request #76 from yusufipk/claude/webm-to-wav-conversion-0169ff
feat(voice-notes): convert recordings to WAV on download
2026-09-08 13:20:12 +03:00
yusufipek 7357f24831 refactor(player): share the cursor idle hook and stop waking on element pauses
Move the cursor idle logic into useCursorIdle and use it from both the video
page and the compare page, which carried its own copy. Only pointer activity
wakes the cursor now: a pause/play pair the element emits on its own (a
rebuffer, a source switch) leaves the idle state alone instead of bringing
the chrome back for a second. The fullscreen-while-paused arming is gone,
nothing rendered it. The scrub test now leaves the player before pressing
the timeline, as the real layout forces.
2026-09-08 13:17:34 +03:00
yusufipek 142dee0c06 feat(voice-notes): convert recordings to WAV on download
MediaRecorder gives us WebM/Opus, and that is exactly what we stored and served back. Browsers and desktop players read it, but no editing suite does: DaVinci Resolve, Premiere and Final Cut all refuse the container outright, so a voice note downloaded byte-for-byte was useless to the editor it was recorded for.

The browser already decodes these formats in order to play them, so the conversion costs nothing but a RIFF header. lib/audio-to-wav.ts decodes through an OfflineAudioContext and writes interleaved 16-bit PCM. This runs at download time rather than at record time, so the stored object stays the small Opus file, uploads keep their 10MB limit, and self-hosted installs gain no server-side ffmpeg dependency.

Voice comments had no download control at all, only a play button, so reviewers were saving files straight off the audio element and getting a bare UUID. They now get a download button on both comments and replies, named after the reviewer and the frame they were talking about, gated on the same download permission as the video and asset downloads. Audio assets get a WAV / Original menu.

Files already in an editable container (wav, mp3, m4a) are handed over untouched: audio assets are not only recordings, and decoding an uploaded master back out would resample it to 48 kHz and requantise it to 16 bit for no gain. When a browser cannot decode the stored format at all, the original is saved and the user is told.
2026-09-08 13:15:28 +03:00
yusufipek b2070c1030 fix(player): re-arm the cursor idle timer when playback changes
The idle countdown that hides the cursor and the play/pause overlay was only
started from mousemove. A cursor that stayed still over the player while a
click, a key or a scrub release started playback never got a countdown, so
the overlay stayed on the video until the mouse moved again.

Arm the timer from one place and rerun it whenever playback or fullscreen
changes, keeping the cursor-over-player state in a ref so the same rule
applies from every entry point.
2026-09-08 12:55:30 +03:00
Yusuf İpek 79bba5e7a1 Merge pull request #74 from yusufipk/claude/compress-video-landing-page-08cc57
feat(landing): replace the hero image with the flow video
2026-09-01 15:28:59 +03:00
Yusuf İpek ab03f7c378 Merge pull request #73 from yusufipk/fix/download-unload-guard
feat(billing): defer the cardless trial for invited collaborators
2026-09-01 15:24:50 +03:00
yusufipek 43cc54c0ae feat(landing): drop the toolbar overlay and gradient from the hero video 2026-09-01 15:23:17 +03:00
yusufipek 54e99cb4ab test(api): register billing/trial in the auth matrix 2026-09-01 15:17:51 +03:00
yusufipek 5f061d1b09 feat(landing): replace the hero image with the flow video 2026-09-01 15:12:40 +03:00
yusufipek 59a64141ee chore(lint): ignore .claude worktree checkouts in eslint
A worktree parked under .claude/worktrees is a separate checkout; eslint
scanning it fails bun run check on files outside this tree.
2026-09-01 15:09:53 +03:00
yusufipek 4b3c3934dd feat(billing): defer the cardless trial for invited collaborators
An account that signs up through an invitation works on the inviter's
billing, so handing it a trial at signup spent its only trial before it
owned anything. The trial is now held back for collaborators and claimed
only explicitly: a Start Free Trial button on the new-workspace and
billing screens calls the new POST /api/billing/trial endpoint, which
grants the once-per-account trial atomically. Nothing starts the clock
as a side effect, and pure collaborators no longer see a trial-ending
banner about work that is not theirs.
2026-09-01 15:07:54 +03:00
Yusuf İpek c5c9da1e30 Merge pull request #70 from yusufipk/claude/landing-page-removal-c07551
feat(landing): remove the Fair Source badge from the hero
2026-08-25 07:44:00 +03:00
yusufipek 07a6bfbef4 feat(landing): remove the Fair Source badge from the hero 2026-08-25 07:37:30 +03:00
Yusuf İpek d894eeb0e4 Merge pull request #69 from yusufipk/fix/download-unload-guard
fix(download): warn before the tab closes mid-download
2026-08-22 13:02:32 +03:00
yusufipek cba8163286 fix(download): warn before the tab closes mid-download
Bunny and direct downloads are pulled through fetch() so we can save them
under our own filename. The browser does not treat that as a download, so
closing the tab discarded everything received so far without a word.

Register a reference counted beforeunload guard while those transfers are
in flight, and while a project manifest is being pulled file by file.
Browser owned downloads (same-origin proxy, the over-10GB fallback, asset
downloads) survive a tab close on their own and stay unguarded.
2026-08-22 12:50:45 +03:00
Yusuf İpek 74e4b4353e Merge pull request #68 from yusufipk/feat/version-subtitles
feat(player): let editors upload subtitles for a version
2026-08-22 08:16:35 +03:00
yusufipek a709ca8544 fix(subtitles): escape a rejected cue tag instead of deleting it
Deleting a tag whole is what lets a filter like this be reassembled around: strip the `<b>` out of `<scr<b>ipt>`
and the two halves close up into a tag nobody wrote. The leftovers are escaped one character at a time instead,
which also covers `-->` in cue text without a second multi-character replacement.

Both are what CodeQL flagged on the branch, js/incomplete-multi-character-sanitization and js/bad-tag-filter.
Neither was reachable as an injection, because the file is served as text/vtt and a cue is parsed by the WebVTT
cue-text parser rather than as HTML, but a sanitiser that cannot be reassembled around is the cheaper thing to own.
2026-08-22 08:01:12 +03:00
yusufipek d981d98cf5 feat(player): let editors upload subtitles for a version
Subtitle tracks hang off a version rather than off a video, because re-editing a cut shifts every cue. The file
always lands in our own S3-compatible storage whatever hosts the video, so a Bunny-hosted cut and an R2 one take the
same path: both already play through our own video element, so a track element is all it takes.

Uploads are normalised before they are stored. Whatever arrives, SRT or WebVTT, is parsed into cues and
re-serialised as a canonical WebVTT file, and anything we did not understand is dropped rather than passed through.
That is what makes it safe to serve a user-supplied text file from our own origin. Files saved out of Windows
editors are decoded as windows-1254 or windows-1252 when they are not valid UTF-8, rather than refused.

A YouTube version cannot carry an uploaded track, so the same CC menu drives YouTube's own captions through the
iframe module API. The embed hides YouTube's controls, so until now those captions were unreachable even when the
video had them.

Uploading and deleting take the editor permission rather than the commenter one: a subtitle is part of the
delivered cut, not a comment attachment.
2026-08-22 07:51:46 +03:00
Yusuf İpek 1f3c6b3f1e Merge pull request #67 from yusufipk/chore/version-0-1-1
chore(release): set the package version to 0.1.1
2026-08-20 17:21:40 +03:00
yusufipek 6f575e48bf chore(release): set the package version to 0.1.1
The tag and the manifest had drifted: v0.1.1 ships the runtime Bunny CDN
config, while package.json still read 0.1.0. bun.lock records no version for
the root workspace, so a frozen install is unaffected.
2026-08-20 17:11:16 +03:00
Yusuf İpek 9826c117dd Merge pull request #66 from yusufipk/fix/bunny-cdn-runtime-config
fix(bunny): read the CDN host from runtime config so Docker images can play video
v0.1.1
2026-08-20 16:19:15 +03:00
yusufipek 0ff8b42b4a fix(bunny): read the CDN host from runtime config so Docker images can play video
NEXT_PUBLIC_BUNNY_CDN_URL is inlined into the client bundle at build time, and
the published image is built by CI without it, so the browser had no host to
build a playlist URL from no matter what the operator set in .env.docker. The
player read the empty URL as a stream that had not finished encoding and sat on
'Video Is Processing', retrying forever.

The server knows the value on every request, so the root layout now serialises
the public settings into a JSON script tag and the browser reads them from
there, falling back to the build-time variable for source builds. The
direct-download allow list came through the same broken path and moves with it.

Closes #60
2026-08-20 15:40:21 +03:00
Yusuf İpek 1f2294c7ac Merge pull request #65 from yusufipk/chore/trim-comment-composer-hint
chore(comments): drop the image paste hint from the composer
2026-08-20 15:22:46 +03:00
yusufipek bcaf9e0c57 chore(comments): drop the image paste hint from the composer 2026-08-20 15:04:17 +03:00
Yusuf İpek 68d23ef1e6 Merge pull request #64 from yusufipk/fix/silent-speed-threshold
fix(player): label only 16x as silent in the speed picker
2026-08-20 11:43:53 +03:00
Yusuf İpek 14ddf19d58 Merge pull request #63 from yusufipk/feat/multi-image-comment-attachments
feat(comments): carry a batch of screenshots on one comment
2026-08-20 11:43:38 +03:00
yusufipek b040fe0dc9 fix(player): label only 16x as silent in the speed picker
The "no audio" note was attached to everything past 4x on a guess about
where the browsers stop pitch-correcting. Playing the ladder through
confirms audio survives 6x and 8x; 16x, the rate Chrome and Firefox clamp
to, is the only silent one. Move the threshold up so the two fast rates
that do carry sound stop advertising otherwise.
2026-08-20 11:36:39 +03:00
yusufipek b9e2006e34 feat(comments): carry a batch of screenshots on one comment
A comment held one image, and the paste handler took the first item off the
clipboard and dropped the rest. Reviewing a cut usually means several
screenshots about the same moment, which meant one comment per screenshot or
one screenshot and a paragraph describing the others. Editing a comment could
not attach anything at all: the edit box had no paste handler, no file picker
and no way to remove what was already there.

A comment now carries up to five images, in the composer, in a reply and in
the editor. One paste stages every image on the clipboard, the file picker
takes a multiple selection, and a drop lands on whichever editor is open. Over
the cap the extras are refused out loud rather than dropped quietly. A single
image still fills the width; several tile into a grid, and either opens full
screen on click.

The images move into their own table. `comments.imageUrl` stays and follows the
first of them, so a reader that has not been updated keeps working, and the
migration copies the existing attachments across so the new table is complete
from the first read. Every path that resolves a URL back to a comment now asks
the new table: R2 cleanup, the orphan sweep, the storage accounting and the
reference checks that decide whether an object can be deleted. Left on the old
column they would have treated images two through five as unreferenced and
swept them.

Detaching an image while editing only breaks the link. The file stays in R2 and
in the assets pane, which is where it is deleted from and where its bytes are
already billed.
2026-08-20 11:01:33 +03:00
Yusuf İpek 689f5bc81b Merge pull request #62 from yusufipk/worktree-bunny-playback-speed
feat(player): raise the playback speed ceiling off YouTube's limit
2026-08-20 11:00:26 +03:00
yusufipek 7b7b1d21b2 feat(player): raise the playback speed ceiling off YouTube's limit
A single speed ladder fed both players, so the 2x cap that YouTube's
iframe API enforces also applied to Bunny and R2, which are plain <video>
elements the browser will play far faster. Pick the ladder per provider:
YouTube keeps 0.25x-2x, the native ones go up to 16x, where Chrome and
Firefox clamp playbackRate. The picker labels everything past 4x as
"no audio", since that is where the browsers stop pitch-correcting and
drop the audio track.
2026-08-20 10:50:42 +03:00
Yusuf İpek 4d9d35164f Merge pull request #61 from yusufipk/feat/upload-ceiling-from-quota
feat(uploads): size one upload against the account's own quota
2026-08-20 09:04:32 +03:00
yusufipek 7b60f3bf76 feat(uploads): size one upload against the account's own quota
The per-file ceiling was a flat 5 GiB from the environment, which is both
too small for a paying account with 200 GB of storage and unaware of what
an upload actually costs. The provider derives its own renditions from the
file (1080p, 720p and down) and bills them to the same account, so a file
allowed to fill the quota exactly is over it by the time it finishes
processing.

The ceiling is now 80% of whatever limit the account is held to: 160 GB on
the plan, 2.4 GB on a cardless trial, and it moves on its own when either
number changes. OPENFRAME_MAX_VIDEO_UPLOAD_BYTES keeps working as an
absolute cap for a host that wants one, where the lower of the two applies,
and an instance running without billing has no quota to divide and falls
back to the flat 5 GiB. The refusal now names the ceiling, which the old
one left the client to guess.

Finalize re-checks only the host cap. Re-deriving the account's ceiling
there would delete a finished upload over a plan that lapsed while the
bytes were in flight, and an upload larger than what was declared is
already caught by the declared-size check beside it.
2026-08-20 08:52:26 +03:00
Yusuf İpek 1d65ed348e Merge pull request #59 from yusufipk/fix/admin-cardless-trial-visibility
fix(admin): show the cardless trial as a trial in the admin panel
2026-08-19 08:10:29 +03:00
yusufipek 60c208b3b3 fix(admin): show the cardless trial as a trial in the admin panel
The cardless trial writes trialEndsAt and nothing else, because there is no
Stripe subscription behind it to report trialing. subscriptionStatus stays
FREE, so every admin view reading that column alone showed a live trial as a
free account: On Trial sat at zero, Free Users counted the trials, the user
table badged them Free with an open-ended Active access, the Trialing filter
returned nobody and the growth scoreboard left them out of the paid accounts
table.

Access has always been resolved from the date (hasBillingAccess), so the
display now follows the same date through getEffectiveBillingStatus. Only FREE
is overridden: any other status means Stripe has an opinion worth showing.
2026-08-18 18:55:41 +03:00
Yusuf İpek bf46860feb Merge pull request #58 from yusufipk/fix/theme-menu-spacing
fix(theme): stop the theme menu from crowding its own top edge
2026-08-18 12:25:22 +03:00
yusufipek 2bc07c47e6 fix(theme): stop the theme menu from crowding its own top edge
The menu had no inner padding, so the first row's icon sat flush against
the popover border and the whole box read as clipped under the header.
It now carries the same padding, offset and fixed width as the account
menu next to it, and the System row uses a lucide icon instead of a
colour emoji that broke the icon column's alignment.
2026-08-18 12:21:02 +03:00
Yusuf İpek a16f7cd856 Merge pull request #57 from yusufipk/fix/new-video-page-upgrade-link
fix(upload): give the Add Video page the upgrade link too
2026-08-18 12:17:39 +03:00