One Engine, Two Audiences: Letting Customers Quote Without Seeing Costs
How to put a customer portal on top of an internal engine that knows every cost: reuse the engine, and make the boundary structurally unable to leak instead of trusting yourself to strip fields.
Sooner or later, if you build internal software, someone is going to ask you to point it at customers. The system that quietly runs the business now has to face the people the business sells to. And the moment that happens, you inherit a problem that looks small and is not: the same engine that knows every number now has to talk to someone who is allowed to see exactly one of them.
In my case the engine is a quoting system for a manufacturer. It knows the material cost per square foot, the labor, the overhead, the markup applied to every line. The ask was a self-service portal: let customers configure a project and get a price themselves, instead of emailing back and forth and waiting on someone internal to quote it. Reusing the engine to produce that price is the easy part. Making sure the customer never sees a cent of the cost behind it, not today, and not three releases from now when you have completely forgotten this was ever a concern, is the actual job.
So this is a piece about how you build that boundary. Not how you remember to hide the cost, but how you make the cost structurally unable to escape, even on the day you stop thinking about it.
The number is the dangerous part
Start by being honest about what you are actually guarding, because it is easy to get this wrong.
The danger is not the price. You want the customer to see the price. The danger is everything sitting behind the price. An internal pricing engine is usually built to expose cost. It itemizes the board, the edgebanding, the hardware, the labor, and the margin stacked on top, because the people running the business need to see all of it to make decisions. That is the whole point of it internally. So the engine you are about to reuse is, by design, a machine for revealing exactly the things your customer must never see.
A customer gets to see one thing: their price. Nothing underneath it. Which means the interesting question is not “how do I compute the price for the portal.” You already have that. The question is “how do I let the portal use a cost-revealing engine without any of the cost coming with it.”
Two ways to do this, and why both of them end badly
When you sit down to solve this, two options present themselves immediately, and both are traps. It is worth walking through why, because the right answer is mostly a reaction to how these two fail.
The first is to reimplement a simpler pricing path just for the portal. It feels clean. The customer-facing calculation is simpler, so you write a smaller version of it that only knows about price, and the cost logic never even exists on that side. The problem is that you have just signed up for two sources of truth. The day someone changes a markup rule, or adds an install fee, or adjusts a tier discount in the real engine, your portal keeps quoting the old way. The two will drift, quietly, and you will not find out until a customer’s self-serve quote does not match what your team would have charged them, in front of the customer. A second implementation of pricing is a second thing to keep correct forever, and you will lose that race.
The second is to expose the real engine and strip the cost fields on the way out. Reuse
everything, and just remember to remove cost, margin, cost_per_sqft, and friends from every
response before it leaves the building. This is worse, even though it looks more responsible. You have
made the safety of the whole boundary depend on you, and every future contributor, remembering to do a
thing on every endpoint, forever. It will hold for a while. Then someone adds a new field, or a new
endpoint, or returns the internal object directly because it was faster, and the leak ships. If your
safety depends on remembering, you have not made it safe. You have scheduled the leak for whenever you
happen to be busy.
So here is the principle both failures point at, and it is the one decision the rest of this follows from: do not make leak-prevention something you do. Make it something the code cannot not do. One engine, guarded at the boundary by construction.
Decision one: reuse the engine, and do not fork it
Reuse the internal pricing logic exactly. The portal endpoints import and call the engine’s own functions, the same ones the internal app calls: create a quote, add a line item, recalculate totals. You do not reimplement the markup, the install rules, or the tier discounts anywhere. There is exactly one place pricing can change, and both audiences run through it.
The payoff is not just less code. It is that a self-serve quote becomes a real quote rather than an approximation that will embarrass someone later. When your customer builds a quote in the portal, it is the literal output of the engine your team trusts, so it cannot disagree with what your team would have charged. That property is worth protecting, and it is the entire reason you do not fork.
If you have read the framework these systems are built on, this is the same idea wearing work clothes: pricing, quoting, and the rest are all functions of one well-formed product model, so you want one engine computing them, not two engines disagreeing. Reuse is how you keep that promise at the boundary.
Decision two: make the leak structurally impossible, not merely avoided
Here is the decision that actually does the work. The portal never serializes an internal object. It returns a separate, cost-free view model that was never given the cost fields in the first place.
class PortalQuote: # the customer-facing view
code: str
rooms: list[PortalRoom] # items carry quantity + the customer's price
total: Money # ...and nothing about cost or margin
# catalog responses are cost-free too: id, code, name, never cost_per_sqft
Look at what this buys you. Because PortalQuote has no cost fields, there is nothing to accidentally
include. You are not stripping cost out on the way through. The customer-facing type was simply never a
shape that could carry it. The guarantee comes from the structure, not from your discipline, and that
is the whole difference between a boundary that holds and one that holds until you are tired.
The instinct to fight here is the lazy one: “I will just return the internal object and leave off the sensitive fields in the response.” Do not. The moment your safety is a thing you remember rather than a thing the type system enforces, you are back to scheduling the leak. Give the outside audience its own shape, and let that shape be incapable of betraying you.
Decision three: derive who the customer is, never let the browser tell you
There is a second leak that has nothing to do with cost, and it is the one people forget. Once customers are calling your engine, you have to be certain each call only touches that customer’s own data. The wrong way is to trust an id that came up from the browser. The right way is to derive ownership from the session token and let the request body have no say in it.
def get_current_customer(token) -> ClientId:
claims = decode(token)
if claims.user_type != "customer":
raise Forbidden()
return claims.client_id # the browser never gets to choose this
# every portal endpoint starts the same way
quote = assert_quote_owned(quote_id, current_customer)
The rule worth internalizing: anything the client can set, the client can tamper with. So the identity that scopes a request should come from something the client cannot forge, which means the token, not the payload. Every portal endpoint starts by asserting ownership against the derived id, and an attempt to read someone else’s quote dies at the door.
Decision four: make a leak a failing test
You have made the boundary safe by construction. Now make it stay that way after you have moved on and forgotten the whole design. Write a test that treats a cost leak as a build failure: drive a quote all the way through the portal, then assert the response carries zero cost fields.
It is a few lines, and it is the cheapest insurance you will ever buy. If someone a year from now wires an internal object straight through because it was the quick path, the test goes red before it ships, not after a customer screenshots their own margin. By-construction safety stops the leaks you can foresee. The test catches the one you cannot, which is some future change you have not imagined yet.
What will bite you anyway
Do all of that and the boundary will hold. Something else will get you instead, and it is worth seeing the shape of it, because the shape is general.
The bug that taught me the most came from duplicating a quote. A customer duplicates an existing quote, and the copy’s total does not match the original. The line items had copied perfectly, identical down to the part, so it was not the items. It was fulfillment.
The original was an old quote, from before the engine had an explicit fulfillment mode. It had no mode set, but it did carry a saved delivery charge sitting on the row. When the duplicate recalculated from scratch, “no mode” fell through to the current default of install, and computed a different number. The old row said one thing implicitly. The new logic assumed another. Nobody was wrong on the day they wrote either one. They just never met until a customer pressed duplicate.
The fix was to stop trusting the absence of a value. Before recalculating, derive the effective mode from what is actually there: if there is a delivery charge, it is delivery; otherwise install; otherwise pickup. Set the mode explicitly, then recalculate, and the totals line up.
Here is the part to carry with you, because it will happen to you in some other form: when you run old records through logic that now expects state to be explicit, distrust their blanks. An empty field is rarely a clean nothing. It is usually a default that made sense when the row was written and has since moved underneath you. This is one of the quiet taxes of building on a system that is already live: your data remembers decisions your code has forgotten.
The rule worth carrying
If you are putting an outside audience on top of an internal system, three things are worth taking with you, and none of them are specific to quoting.
Reuse the core logic. A second implementation is a second source of truth, and it will drift away from the first until the gap shows up in front of the person you least wanted to see it. Make the boundary safe by construction, not by vigilance: give the outside audience its own shape, one that cannot carry what it must not reveal, so the guarantee does not depend on anyone remembering. And distrust the blanks in your old data, because an empty field is often a default in disguise.
The deeper version of all three is the same instinct. Do not build a system that is safe as long as you stay careful. Build one that is safe when you forget, because you will. If you have wrapped a customer-facing layer around an internal system, it is worth asking yourself honestly: where would your first leak actually come from, the serializer, or the old data nobody has looked at in a year? The answer tells you where to spend your care. The rest of the platform is mostly this same question, asked again at every boundary.