Every twenty or twenty five days someone at the company would sit down and build a rate card.
A rate card is a spreadsheet. Countries down one side, weight slabs across the top, a price in every cell, one sheet per carrier. A customer asks "what does it cost me to send 5kg to Germany", and the rate card is the answer, except the rate card has to answer that question for every country and every weight at once, because you do not want to be on a call saying "let me check and get back to you".
Building one took four to five days. Not four to five days of thinking. Four to five days of opening a vendor portal, typing in a country, typing in a weight, waiting, copying a number into Excel, and doing it again. Then the vendors changed their rates and you did it again from scratch.
I did the maths on it once and stopped enjoying my afternoon. Twenty six countries times thirty weight slabs times four vendors is 3,120 lookups. At ten seconds each, assuming nobody blinks or gets a phone call, that is eight and a half hours of pure typing. Nobody was doing it at ten seconds each, and nobody was doing all 3,120. They were doing a subset, for the lanes that came up most, and everything else was a guess or a phone call.
So the number that actually mattered was not the four to five days. It was that we could not answer questions we did not already know the answer to.
Why you cannot just call the API
One note before I go further. We buy from four vendors, and I am going to call them Vendor A through Vendor D for the rest of this post. Which ones they are is not the interesting part, and our commercial terms with them are not mine to publish. What matters is that they behave differently, and most of what follows is about the ways they differ.
- Vendor A resells the widest catalogue and is the only one that prices on real dimensions rather than a weight you hand it.
- Vendor B is the slowest by a distance and the only one that publishes a rate limit.
- Vendor C sits in the middle on everything.
- Vendor D is a direct account with a carrier rather than a reseller, so it returns exactly one service per call.
Now, the obvious fix for the spreadsheet problem is "call the vendor APIs instead of typing into their portal". I thought that too.
The problem is the shape of the API. Every vendor we work with exposes a rate endpoint that answers exactly one question: one origin, one destination, one weight, one box, one shipment. You give it a shipment and it gives you back the services it can fly and what each one costs. That is a perfectly reasonable API if a human is booking a parcel. It is useless if you want to compare anything.
Comparison needs a grid. The API gives you a point. To get the grid you need 3,120 points, and 3,120 sequential HTTP calls against four vendors is not something that happens inside a web request. It is not something that happens inside anything with a timeout, actually, which is most things.
There is also a rate limit problem. Vendor B publishes ratelimit-policy: 10;w=60 on every response. Ten calls a minute. Their share of the matrix alone is 780 calls, which at ten a minute is 78 minutes of continuous polite requesting, and that is assuming nothing fails.
So the answer was never going to be "fetch it when someone asks". The answer had to be that the data already lives with us, and the question becomes a SQL query.
The inversion
Here is the whole idea in one line: stop asking the vendors at read time, ask them on a schedule, and store every answer.
A cron job walks the matrix every five nights, hits every vendor for every country at every weight, and writes each answer to Postgres. Building a rate card stops being a data collection problem and becomes a data formatting problem. The Excel file is generated from a table.
That is the part that sounds easy in a blog post. The interesting bits are all in what "the matrix" actually means and what happens when a night goes wrong.
Defining the matrix was harder than building it
Before writing any job I had to decide what a cell of this grid actually is, and it turns out a cell is a surprisingly opinionated thing.
Destination. A vendor prices to a postcode, not to a country. But a rate card has a country column, because customers think in countries. So each country in the matrix carries one canonical postcode, and I picked the capital city's for every one of them. London SW1A, Berlin 10115, Washington 20001.
This rule is consistently wrong in a known way and I decided that was fine. Canberra is not Sydney. Pretoria is not Johannesburg. Washington DC is not New York. Express carriers price zones, and the capital is not always the commercial centre, so a few rows describe a real lane that is not the busiest lane in that country. What makes it safe is that the rule is consistent and every stored row records which postcode produced it. If a row ever looks wrong against a live quote, it is one line to change.
Then the Gulf broke it. The UAE, Qatar and Hong Kong do not have postal codes. The vendor APIs demand the field anyway, so those rows carry 00000 and a syntheticPostcode: true flag that travels with the data, not just with the config, because "why is Dubai empty" is a question somebody asks six months later when the config file has moved on.
Weight. Thirty slabs, log spaced:
0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2,
2.5, 3, 3.5, 4, 4.5, 5,
6, 7, 8, 9, 10,
12, 14, 16, 18, 20,
25, 30, 40, 50, 75, 100
Dense below 5kg, coarse above 20kg. Two reasons stacked on each other: most parcels sit under 5kg, and the price per kg moves fastest down there, so if anyone interpolates between two samples that is where a straight line is least wrong. Above 20kg the curves flatten and an extra sample buys nothing.
The box. This one I did not see coming.
Three of the four vendors take a chargeable weight directly. You say "5kg" and they price 5kg. Vendor A does not. Vendor A takes real dimensions, computes its own volumetric weight, and charges on whichever is higher. Send it no dimensions and it refuses to quote. Send it a big box and your row labelled 2kg is quietly holding the price of a 6kg shipment.
So every slab needs a box, and the box has to guarantee that volumetric stays under actual, or the column label stops being true. The tempting answer is a 1x1x1 cube, which guarantees it and also describes a parcel made of neutronium. A vendor is entitled to reject that, and the stored dimensions become useless as a record of what was asked.
What I settled on is a cube sized so volumetric lands at about half the actual weight:
volumetric = side³ / 5000, and we want that to equal weight / 2
so side = cbrt(2500 × weight)
Nine centimetres at 250 grams, 63 centimetres at 100kg. Dense but physically ordinary. Rounded up to whole centimetres because Vendor A rounds up anyway, and rounding here means the number I store is exactly the number I sent.
Why all of this is a TypeScript file and not an admin screen. An admin screen for editing countries and postcodes would be friendlier. It would also be the wrong call, and this is the design decision I feel most strongly about in the whole system. A canonical postcode is not a preference. It is the definition of what every stored number means. Change London from SW1A to somewhere in the Highlands and every GB row silently starts answering a different question, with nothing in the data to say so. That belongs in a commit somebody reviewed, next to the reasoning, not in a text input with a Save button.
The file also carries a SWEEP_CONFIG_VERSION, bumped whenever a change makes new rows incomparable with old ones. A moved slab, a changed postcode, a changed box formula. Not for adding a country, because existing lanes stay exactly as comparable as they were. And every run stores a full verbatim copy of the config it used, denormalised on purpose, because in two years the config file will say something different and that JSON blob is the only record of what those numbers ever meant.
Why Inngest, and what a "lane" is
About a year ago I hit Vercel's function timeout for the first time and had no idea what to do with a job that simply could not finish inside a request. That is what pushed me to Inngest, and it has since become one of the load bearing pieces of the whole product. Emails, PDF generation, Excel generation, carrier bookings, and now this.
For the sweep, the thing I actually needed was not "background job". It was durable steps. A three hour job that talks to four flaky third party APIs is going to get interrupted, and I wanted the interruption to cost one call rather than the whole night.
The unit of work I settled on is a lane: one vendor, one country, thirty weights.
That decision is worth explaining because I went back and forth on it. The two obvious alternatives:
One run per cell (3,120 runs). Maximum isolation, every cell independent. But 3,120 events fanned out at once, 3,120 runs of platform overhead, and nowhere natural to hold "I am currently walking a weight ladder for this vendor". Pacing becomes something you have to coordinate externally instead of something the loop just does.
One run per vendor (4 runs). Cheap on events, and a disaster on failure. One run holding 780 sequential calls means the failure blast radius is a quarter of the matrix, and a single wedged run takes out every country for that vendor.
The lane sits in the middle. 104 runs, each holding thirty steps. A cell is a step, so it retries on its own and memoises when it succeeds. The loop over weights is ordinary for loop code, which means pacing is just a sleep between iterations. And a country dying takes one country with it.
The whole system is three Inngest functions:
plan-rate-sweep cron, fans out
↓ 104 events
sweep-rate-lane one vendor × one country × 30 weights
↓ 1 event (from the last lane to finish)
finalise-rate-sweep count, judge, alert if bad
The Inngest decisions that actually mattered
Fan out in one send, not 104
await step.sendEvent(
"fan-out-lanes",
vendorIds.flatMap((vendorId) =>
SWEEP_COUNTRIES.map((country) => ({
name: rateSweepLaneRequested.name,
data: { runId, vendorId, countryCode: country.code },
})),
),
);
One step, one batch. 104 separate sends would be 104 HTTP round trips and 104 chances for a partial fan out where half the matrix never gets asked. As a single durable step it either happens or it retries.
Concurrency keyed on vendor, and what it does not do
concurrency: [{ limit: 1, key: "event.data.vendorId" }]
I wrote a comment above this saying "one lane per vendor at a time", and that comment was wrong for weeks in a way that mattered.
What it actually means is exactly one step per vendor executes at a time. A run that is sleeping is not executing, so it gives up the slot. All 26 of a vendor's lanes start within a few minutes of each other and then interleave through that single slot, advancing roughly in lockstep.
Which means the pacing sleep I put between cells paces one lane, not the vendor. The other 25 lanes are free to use the slot while this one sleeps. So the real per vendor call rate is one call per (vendor latency + platform step overhead), and my configured pacing numbers turned out to be a ceiling that has never been reached.
We got lucky. The rates I measured afterwards happened to sit under every limit. If a vendor ever needs a genuinely enforced ceiling, that needs a throttle, not a sleep, and I know that now because I measured rather than trusted my own comment.
The 429 backoff is structural, not a number
There is no decay factor anywhere in this codebase. When a vendor rate limits us, the cell throws Inngest's RetryAfterError carrying the vendor's own Retry-After value, so the step waits exactly as long as they asked.
The nice part is what falls out of that. Because that run holds the vendor's only concurrency slot while it waits, every other lane for that vendor waits too. One 429 pauses the entire vendor for exactly the interval the vendor named, using machinery that survives a deploy and a crash. A hand rolled pace decay would have had to recompute identically on every replay to stay deterministic, and it would still only have slowed the one lane that happened to see the 429.
Sleeps are steps, not setTimeout
step.sleep suspends the run entirely and resumes it later. A setTimeout would hold a serverless handler open doing nothing and burn billed compute for it. It is also the difference between a job that survives a deploy mid run and one that silently stops halfway through the matrix, which on a five day cadence means nobody notices for five days.
An atomic counter, not polling
Something has to notice when all 104 lanes are done. Polling a count(*) over a table this size, repeatedly, is the version of this that does not scale.
Instead the run row carries lanesCompleted, and every lane atomically increments it as its last act and reads back the post increment value. Exactly one lane can ever see itself as the last one, and that lane fires the finalise event. Firing an event rather than finalising inline keeps the closing work out of a run whose concurrency slot the next lane is already waiting on.
Finalise is its own function with idempotency
Two different things can decide a run is over: the last lane, and the planner's backstop when the counter never gets there. Both are expected. Putting the closing logic in one function that both send an event to means there is one implementation of "was this run any good" instead of two that drift apart.
idempotency: "event.data.runId" makes the duplicate harmless at the platform level, and the notification carries its own dedupe key so it is harmless at the inbox level too. The failure I was guarding against is an ops team getting two alerts for one problem and learning to ignore both.
A healthy run says nothing
This runs every five days forever. A job that reports its own success on a schedule trains everyone to filter it, so that when it finally does have something to say, nobody reads it. Only a degraded or failed run sends anything.
Then I shipped it, and it lied to me
The first version ran. It produced thousands of rows. The dashboard said COMPLETED. I was pleased with myself for about a week.
Then I actually looked at the data, and the United States was empty.
Not "the United States had bad prices". The single busiest lane in the matrix, for the vendor with the best coverage, had no rows at all. Neither did Germany. Neither did the UK. The countries with rows were mostly the small ones.
Bug one: the Prisma transaction timeout
Here is the write path in the first version, roughly. For one cell: open a transaction, upsert the call row, then for each product the vendor returned, insert the snapshot row and then its charge rows.
Prisma defaults an interactive transaction to timeout: 5000 and maxWait: 2000. Those defaults are sized for an application server sitting in the same rack as its database. Mine is not. Serverless functions talking to Neon over the open internet, where a transaction's cost is its number of round trips multiplied by that latency, not the work the database actually does.
Count the round trips. One upsert, then three per product. At eight products, which is what a well served lane like the USA returns, that is 27 sequential round trips inside one transaction. 27 round trips fit inside 5,000ms only while latency stays under about 185ms. Above that the transaction expires, the whole cell rolls back, and the lane dies having recorded nothing.
Read that back and the shape of the bug is obvious: the better a vendor's coverage on a lane, the more products it returns, the more round trips the write costs, the more likely the write fails. The system was systematically dropping exactly the lanes it was built to answer questions about. Brazil, where one vendor offers a single service, wrote fine every time.
This is the bug that taught me the most, and not because transaction timeouts are hard. Because the failure was correlated with value and there was nothing in the output that said so. A missing row and a row that was never asked for looked identical.
The fix was two things. First, the write path got rewritten so almost none of it is inside the transaction. Carrier classification, canonical charge naming and decimal coercion are pure CPU work with no database in them, and the old loop was holding a transaction open across all of it. Now they happen before BEGIN. Snapshot IDs are generated with randomUUID() up front rather than read back from the insert, so the child charge rows already know their parent's ID and everything collapses into createMany. The write is now a constant six round trips whatever the vendor returns, whether that is one product or twelve.
Second, I raised the client wide defaults anyway, to timeout: 20_000 and maxWait: 10_000. The sweep no longer needs a long budget, but when I went looking, every other multi statement transaction in the app was one latency spike away from the same failure, and none of them had noticed either. Raising a timeout cannot break a transaction that was already committing. It only stops one that was working from being cut off part way.
Bug two: one bad cell was killing twenty nine good ones
retries: 3 is per step. What I had not internalised is what happens when a step exhausts them: it throws into the function body. And a body that does not catch it ends the run, with every remaining slab unasked and unrecorded.
So one failed write at 0.25kg, the very first slab, killed the entire lane. That is how a country ends up with no rates at all rather than 29 out of 30.
The rule I wrote across the top of that file and then implemented is: a bad cell costs one cell. Every cell is wrapped, and a cell that gives up is recorded as a gap and stepped over.
The catch is deliberately narrow, and this bit took me a while to get right:
if (!isFailedStep(error)) throw error;
Only a step reporting its own exhausted retries gets swallowed. A step that has not run yet does not reject at all, since the SDK suspends the run on an unsettled promise, so nothing here can interfere with normal control flow. But anything that is not this step reporting its own failure is a bug in my code or in the SDK, and it must be allowed to fail the lane loudly rather than get quietly filed away as "the vendor was flaky".
isFailedStep checks instanceof StepError and then falls back to checking the error's name, because the SDK duck types its own error classes the same way, and a bundler ending up with two copies of the package would make the instance check silently false. Which here would mean failing a whole lane over one bad cell, the exact thing the code exists to prevent.
Bug three: the backstop that ate a third of the matrix
The planner does not exit after fanning out. It sleeps past the point the sweep should have finished, then force closes anything still marked RUNNING.
That backstop exists because a run stuck in RUNNING is not cosmetic. The next cron refuses to start while one is in flight, so a single stranded lane would quietly stop the sweep from ever running again, and you would find out five or ten or twenty days later.
I sized the sleep off the configured pacing. Slabs times countries divided by calls per minute, plus a margin. On the 20th of August it fired at 158 minutes, one minute after the estimate ran out, while Vendor B was still legitimately grinding through its lanes. The run got closed, marked COMPLETED, and the rest of the matrix was never collected.
The mistake was using an idealised number where a measured one was available. The pacing figure was never the binding constraint, as I found out later when I understood what the concurrency key was really doing. So now the estimate comes from measured seconds per cell, taken off real run data:
Vendor D 4.0 s/cell (1.1 s vendor latency)
Vendor C 4.8 s/cell (0.6 s)
Vendor A 8.1 s/cell (0.7 s)
Vendor B 11.3 s/cell (5.0 s, it is simply a slow API)
Rounded up, with a 90 minute margin on top, and derived from a function so that adding a country or a vendor extends the backstop automatically instead of quietly making it too short again.
The asymmetry is the lesson. A backstop that fires an hour late costs nothing at all. One that fires ten minutes early costs a third of the matrix.
Bug four: the health check that could not see absence
This is my favourite one, because the code was correct and the arithmetic was correct and the answer was still nonsense.
Finalise judges each vendor: attempted, of which ok, of which failed. Ratio over a threshold means degraded, means alert.
Every one of those numbers is derived from rows that exist. A lane that died before writing anything contributes nothing to the numerator and nothing to the denominator. It is arithmetically invisible. So a vendor that recorded 346 of its 780 cells scored a 0% failure rate, because the 434 cells it never wrote were never counted as anything at all.
The run that lost a fifth of the matrix reported itself perfectly healthy. It was not lying, exactly. It was answering a question about the rows it had, and I had asked the wrong question.
The denominator is now what the run set out to record, taken from the run's own stored plan, and cells with no row are counted as missing. Absence is the loudest signal this system has and it finally has somewhere to be counted.
I added a second alarm alongside the ratio, because a ratio on its own is still not enough. Losing every USA cell for one vendor is 30 cells out of 780, under 4%, nowhere near any sane threshold. It is also the single worst thing that can happen to this dataset, a whole country vanishing from a vendor's coverage with nothing in the table to say so. So an empty lane is a degradation on its own terms, whatever the ratio says.
All of that arithmetic lives in a pure module with no Prisma and no network, because the same judgement is needed in two places that must never disagree: the finalise function deciding whether to wake someone up, and the admin screen showing a person the same numbers. Those had already drifted once when they were two implementations.
Absence has to mean exactly one thing
Running through all four of those bugs is one idea, and it is the thing I would keep if I had to throw the rest of this post away.
The tempting version of the write path skips the row when there is nothing to store. No quote, no row. It produces a table where "Vendor B has no row for 40kg to Brazil" could mean Vendor B does not fly that lane, or Vendor B was down that night, or the sweep never got that far. Those need three completely different responses and the data cannot tell them apart.
So every attempt lands as a row with a status on it: OK, NO_SERVICE, AUTH_ERROR, RATE_LIMITED, TIMEOUT, VENDOR_ERROR, SKIPPED. Failure rows are written before the code throws to trigger a retry, because if the retries are exhausted that row is the only evidence left of what happened, and a row that only appears on success is a row that goes missing exactly when it matters.
Cells that never got asked at all get filled in too. When a vendor returns a 401 the whole vendor is abandoned for that run, and the remaining slabs are written as SKIPPED in one batched insert with skipDuplicates, so the gap in the table carries its own explanation. That used to be one function call, one transaction and one durable step per cell, which meant abandoning a vendor on its first slab cost 29 more round trips to say nothing happened.
Absence in that table now means one thing and one thing only: never attempted.
The part where FedEx is three different companies
The sweep stores every service a vendor returns, not the cheapest one. One run produced 9,123 rate rows from 2,310 calls, because Vendor A returns about 6.7 services per call, Vendor B 5.5, Vendor C 4.1, and our direct account exactly 1.
What was missing at first was normalisation. productName is free text and three vendors spell the same carrier three ways:
"FEDEX DEL" Vendor B
"Fedex" Vendor C
"Fedex DL - with Pickup" Vendor A
Group on the raw label and those are three carriers. Which makes the single question this whole system exists to answer, "whose FedEx is cheapest to the US at 5kg", literally inexpressible in SQL.
So every snapshot row also carries a normalised carrier, derived from the label at write time by an ordered rule list where first match wins. Real carriers are matched before reseller brands, so a label like "Premium UPS Ground" from a reseller files under UPS and not under the reseller. One rule is checked before all the others, because that carrier is also one of our vendors and its name shows up in both roles. Anything that matches nothing goes to OTHER and surfaces on a review list on the admin screen, never to a vendor brand fallback, because a wrong answer that looks confident is worse than an obvious gap.
carrier is a String and not an enum on purpose. A new carrier appearing in a vendor's catalogue should be a code rule plus a backfill, not a schema migration, because otherwise the sweep has to file it under OTHER until someone ships one.
The same pass extracts three more things off the label, and these matter as much as the carrier:
-
dutyMode, because a duty paid rate and a duty unpaid rate are not two prices for the same thing. The cheaper one leaves a customs bill for the customer on delivery. Rank them together and you produce a quote that is wrong in a way nobody notices until the shipment lands. -
contentType, documents versus goods, priced completely differently. -
pickupIncluded, nullable, because null means the label did not mention pickup. Recording that asfalsewould be inventing a fact about what the price covers.
Every comparison query filters on isComparable and dutyMode. isComparable is set at write time and is just currency === "INR", because one vendor sometimes quotes in something else and a cheapest-of query putting 400 USD against 4,000 INR would be confidently wrong rather than merely unhelpful. Nothing in this system invents an exchange rate.
One correction I had to make on myself, since I got it wrong out loud first: reading the first run I concluded our direct carrier account was uniformly beaten by the resellers. It is not. It wins 210 of the 556 cells where the same carrier is available both ways. It is catastrophically bad on Kuwait specifically, over 300% dearer than the same carrier bought through a reseller, and I had generalised from the Gulf rows I happened to look at first.
Five days to eight seconds
With the matrix in Postgres, the rate card became a formatting problem.
There is now a form: pick countries, pick weights, pick an audience, pick a markup. It queries the stored matrix, groups by carrier, and builds a formatted xlsx with exceljs. One sheet per carrier, logos, frozen panes, a terms sheet. It takes about eight seconds.
The job that used to take four to five days of typing takes eight seconds, and it covers 3,120 combinations instead of the fifty someone had the patience for.
A few decisions in that generator that were not obvious:
exceljs, not the xlsx package already installed. SheetJS community edition cannot do fills, fonts, borders or images, which is the entire requirement for a document that goes to a customer.
Audience is the highest stakes field in the codebase. CUSTOMER means marked up prices with vendor names masked behind our own branding. INTERNAL means raw carrier cost, vendors named, stamped on every sheet, filename ending in -INTERNAL. An internal file is our buying price and therefore our margin, in a spreadsheet, with a customer's name on the cover if you get one field wrong. The zod schema refuses to produce a customer file at 0% markup for exactly that reason, and only customer files are ever uploaded to storage, guarded inside the storage module rather than at the call site because call sites get copied.
Logos are baked in as base64 rather than read from /public at runtime, because /public is not guaranteed to exist on a serverless filesystem. And each image is registered once per workbook behind a WeakMap, because calling addImage per sheet tripled a file to 380KB.
Column A holds weights and nothing else. Excel clips a floating image at a frozen pane boundary, so a logo anchored in the frozen first column came out cut in half. Everything starts at column B now, and there are two tests enforcing it.
The workbook states no transit time at all. That was a business decision that came later. A day count in writing gets read as a promise, and the customs and uplift delays that break it are nobody's to control. The field is not even selected in the query, so the builder has no number it could accidentally print.
What it actually changed
The honest accounting:
- A rate card went from four to five days to under ten seconds, and from a partial grid to a complete one.
- Questions we could not previously ask are now a SQL query. Whose FedEx is cheapest to Germany at 2kg. Which vendor's fuel surcharge moved this quarter. Whether our direct carrier contract is worth keeping, which turned out to have a much more interesting answer than I expected.
- The data is explicitly indicative. Quotation sheets yes, booking prices never. Bookings still price live against the vendor, because a stored number is a number from up to five nights ago and Vendor A's duty figure is only valid near the declared value we pinned. A rate card that is roughly right is useful. A charged price that is roughly right is a refund.
- Rows older than seven days are refused for quotation, because the cadence is five days and anything past seven means a sweep was missed and nobody noticed.
- Successful raw vendor responses are pruned after 30 days. Failures keep theirs forever, since a failure is the row somebody opens months later asking what actually came back. Without the pruning the raw column alone was roughly a gigabyte a year.
What I would tell myself before starting
Three things.
A background job's real interface is its failure output. I spent my design effort on the happy path, on pacing and concurrency and the shape of the fan out, and all four of the bugs that hurt were in what the system said about itself when things went wrong. The status enum on the calls table, the expected-versus-attempted denominator, the gap rows: all of that is unglamorous bookkeeping and all of it is what makes the data trustworthy.
Measure before you size anything. The backstop was sized off a number I derived on paper, and the number was wrong by more than an hour. The concurrency comment I wrote confidently was wrong about what the concurrency key does. Both were fixed the same way, by reading run data instead of my own reasoning.
Correlated failure is the dangerous kind. Random failures are visible. The Prisma timeout failed hardest on the highest value lanes, and the health check could not see the failures at all, so two bugs stacked into a system that was quietly wrong in exactly the places I most needed it to be right. The 5% of runs that fail randomly will get noticed. The 5% that fail for a reason that correlates with importance will not.
The sweep has run cleanly for a while now. It goes off at 01:00 IST every fifth day, takes two and a bit hours, says nothing when it works, and I mostly forget it exists until someone asks for a rate card and gets it before they have finished asking.





Top comments (0)