Do the arithmetic on your own dashboard before you decide, because the fee structure is not one number. Stripe's published pricing lists a fee for receiving a dispute and a separate fee for countering one, both fifteen dollars on standard US pricing at the time I am writing this. What that means for your net if you win depends on which of those apply to your account, and the dashboard shows it on the dispute itself. Do not take a blog post's word for it, including mine - open the dispute and read the fee lines.
The other thing to know: you get one submission. Stripe forwards your evidence and files to the issuing bank immediately and you cannot edit or add to it afterwards. So do not fire off a half-built response at 1am.
Then the issuer takes their time. Expect the outcome in the range of two to two and a half months, not days.
You control the queries you remember to write. RLS exists for the one written at 6pm on a Friday by someone adding an admin export. The performance problem here has a two-character fix; "remove the safety net" is a strange trade for that.
That is the whole fix and it is worth understanding why. Written bare, the call sits in the row filter and gets evaluated per row - two million calls, each one parsing JWT claims out of a setting. Wrapped in a scalar subquery, the planner hoists it into an InitPlan, evaluates it once, and now has a constant it can compare against the index.
While you are in there, add the role to the policy:
create policy docs_own on documents
for select to authenticated
using ( user_id = (select auth.uid()) );
Without to authenticated the expression is also evaluated for anonymous requests that could never match anything.
For debugging this class of thing without a browser in the loop, reproduce it in psql inside a transaction you roll back:
begin;
set local role authenticated;
set local request.jwt.claims = '{"sub":"...","org_id":"..."}';
insert into tasks (title, org_id) values ('x', '...');
rollback;
Ten seconds per iteration instead of a deploy. This is also the shape of the test you should be writing anyway.
Matcher change shipped this morning, ticket volume went to zero. Moving the limiter to the mutation layer this week - you are right that IP keying was the other half of it, we had a customer with 30 people on one office IP.
Time is the hidden line item. When you had 12 free hours a week you filled them with cheap things because you were exhausted. With 60 free hours you take up hobbies, meet people for lunch, drive places on a Tuesday. None of it looks like extravagance and all of it costs money. I'd count it as the price of the thing you bought rather than a budgeting failure.
Before you rearchitect, check whether your 8ms is inherent or self-inflicted. Two things usually account for most of it:
(select auth.uid()) inside the subquery - which you have, good.
An index on memberships(user_id, org_id). Without it that subquery is a scan, and at 1,600 rows it is a fast scan that still costs you a plan node per query.
Then wrap the whole thing in a stable security definer function user_orgs() and call it as org_id in (select org_id from user_orgs()). On our data that took the per-query overhead from 7ms to under 1ms without touching tokens at all.
This is the better version of what I wrote. A lint rule is a speed bump; a credential the process cannot read is a wall. The only cost is that your local dev setup now needs two connection strings, which is a fine price.
The thing that helped me more than any allocation was writing down, before I quit, exactly which expenses I would cut and by how much if the portfolio dropped more than 20%. Two pages, kept in the same folder as the spreadsheet. When it actually happened I didn't have to make good decisions in a bad mood, I just did the thing past me had already decided.
Make the dangerous client hard to reach and easy to spot:
One file, one export, named so it cannot be mistaken: adminDb from lib/admin-db.ts. Never a default export, never re-exported.
An eslint no-restricted-imports rule that fails the build for any file outside lib/jobs/** importing it. This is fifteen lines of config and it is the actual fix.
Keep the tenant predicate in the query even when using it. Defence in depth costs you one where clause.
And stop treating "RLS makes the plan bad" as a reason to bypass RLS. It is almost always a bare function call in the predicate, which is a two-character fix, not an architecture change.
Send a short written note, not a chase, that states the invoice number, the amount, the original due date, and asks them to confirm a payment date in writing. Polite, specific, no adjectives. The point isn't guilt, it's that a request for a date is much harder to answer with 'it's in the system' and it creates a paper trail you'll want if this drags to 90 days.
21% accept is fine for cold with no shared context. It drops fast if your headline reads like a pitch and rises a lot if it names the specific problem your buyer has.
The cap is real and it's a rolling seven day window rather than a calendar week, which people get wrong and then decide they've been shadowbanned on a Monday. The way to beat it isn't volume tricks, it's making each invite cheaper: comment on something they wrote first, wait a few days, then invite. Accept rate roughly doubles for me and the conversation starts warm.
Practical migration warning: alter table ... enable row level security with no policies denies everything to everyone except the owner. If your app connects as the owner nothing appears to change, which is worse - you will think it works and it is doing nothing. If it connects as anything else, every query returns zero rows instantly.
Either way, do it one table at a time behind a migration you can reverse, with the read path tested before you move on.
The pooler detail matters more than it sounds. With transaction-mode pooling set local is fine because it is scoped to the transaction, but a plain set leaks into whoever gets that connection next - which is the worst possible bug, because it is intermittent and it hands one tenant another tenant's context.
Do one cluster end to end first and measure it for six weeks before committing to the other seven. Cheap insurance and it gives you something concrete to show your boss.
Whichever you choose, define a decision deadline in writing. Something like 'requirements for each two week block are locked on the Friday before it starts, changes after that move into the next block'. Gives them freedom to be early-stage without it costing you the schedule.
Agreed on the input, though I'd add inflation assumptions matter just as much and get less attention. Two percent versus three over 26 years is an enormous swing in the final number.
begin;
set local role authenticated;
set local request.jwt.claims = '{"sub":"user-a"}';
select count(*) from documents; -- expect 12
set local request.jwt.claims = '{"sub":"user-b"}';
select count(*) from documents; -- expect 0
rollback;
set local is scoped to the transaction so nothing leaks, and you can switch identity mid-transaction as many times as you like. Save it as a .sql file, run it with psql -f after every policy change. Three tabs becomes one command.
Mine faded around the first full tax year, once I had a complete cycle of actual numbers instead of projections. Turns out I didn't want reassurance, I wanted data.