Architecture
Put your pricing rules in the database, not in your code
A used phone valuation engine where the prices, the deductions, the order they apply in, and even which questions the form asks are all rows in a table. What that buys, and the two things it quietly costs.
Used phone prices move weekly. A new model lands and last year's flagship drops. A supplier changes what they will pay for cracked screens. Somebody notices the business has been overpaying for 128 GB variants for a month. None of this is a software problem, and none of it should require a developer.
But if your pricing lives in code, all of it does. A price correction becomes a pull request, a review, a deploy, and a wait. The person who actually knows what a device is worth, the one buying them all day, cannot act on what they know. They file a ticket and hope. Meanwhile the business keeps quoting the wrong number to every customer who visits.
The advice to avoid hardcoding configuration is old and everybody nods at it. What is rarely shown is a domain where the rules are genuinely complicated, where the ordering matters, and where the interesting claim is not that prices live in a table but that the shape of the calculation does too, including which questions the customer is asked. This is that system, and the parts of it that are worth stealing.
One row per variant, carrying its own rules
The unit of pricing is not a model, it is a variant: a model at a given storage and RAM. An iPhone 13 at 128 GB and the same phone at 256 GB are two rows, and they are free to behave completely differently. Each row carries a base price and, next to it, its own full set of depreciation values.
That last part is what makes the design work. It would be easier to store one global table of deductions and apply it everywhere: cracked screen costs 20 percent, no charger costs 500 rupees, done. But a cracked screen does not depreciate a budget Android the way it depreciates a flagship, and the people who buy these devices know that. Attaching the rules to the variant means two devices can price differently with no branching anywhere in the code.
SELECT
s.price,
s.depreciation_age_under_3_months,
s.depreciation_age_3_to_6_months,
s.depreciation_age_6_to_11_months,
s.depreciation_age_above_11_months,
s.depreciation_flawless,
s.depreciation_good,
s.depreciation_average,
s.depreciation_below_average,
s.depreciation_touchscreen_not_working,
s.depreciation_no_charger,
s.ask_network_question,
s.tablet_switch_off_price,
b.name AS brand_name
FROM specs s
JOIN models m ON s.model_id = m.id
JOIN brands b ON m.brand_id = b.id
WHERE s.id = ?Note what the code does with the result: nothing clever. It reads numbers off a row. Every branch that follows exists because the domain genuinely branches, not because the prices vary.
Age resolves first, and that ordering is the whole thing
This is the part that is easy to get wrong and expensive to get wrong quietly. Age is not one deduction among many. It resolves first and produces an age adjusted base, and every condition deduction after that is a percentage of the adjusted figure rather than of the original price.
const calcAgeAdjustedBase = () => {
if (!ageCategory) return basePrice
const agePct = Number(spec[`depreciation_age_${ageCategory}`]) || 0
if (agePct <= 0) return basePrice
return basePrice - basePrice * (agePct / 100)
}
// Everything after this point is a percentage of ageAdjustedBase, not basePrice.
const addCondition = (pct) => {
const p = Number(pct) || 0
if (p > 0) totalConditionDeduction += ageAdjustedBase * (p / 100)
}Work an example. Take a device with a 40,000 rupee base price, in the 6 to 11 months band at 20 percent, graded average at 15 percent. Age first gives an adjusted base of 32,000. The condition deduction is 15 percent of 32,000, which is 4,800, for a final offer of 27,200.
Now apply the same two rules in the other order, or apply both to the original price, which is what you get if you write the obvious loop over a list of percentages. Fifteen percent of 40,000 is 6,000, for a final offer of 26,000. Same inputs, same rules, 1,200 rupees apart. Across a few hundred devices a month that is a real number, and nothing in the code looks wrong. Both versions read fine.
If your rules compound, the order they apply in is part of the specification, not an implementation detail. Write it down somewhere a reader will find it, because the code will not tell them.
Percentages and flat amounts are not the same kind of rule
Some deductions scale with the value of the device and some do not. A cracked screen on a flagship costs more to replace than a cracked screen on a budget phone, so it is a percentage. A missing charger costs what a charger costs, whatever it was plugged into, so it is a flat amount.
ALTER TABLE specs
ADD COLUMN depreciation_no_charger DECIMAL(10,2) NOT NULL DEFAULT 500.00,
ADD COLUMN depreciation_no_bill DECIMAL(10,2) NOT NULL DEFAULT 500.00;The default matters more than it looks. Thousands of variants get bulk loaded, and nobody is going to fill in every accessory deduction for every one of them. A NOT NULL default means an unconfigured row still produces a defensible quote instead of silently deducting nothing.
The same column can mean two different things
Here is the sharpest edge of this whole approach, and I have not seen it discussed anywhere.
Once the rules live in the database, the schema stops carrying their meaning. DECIMAL(10,2) tells you a number has two decimal places. It does not tell you whether that number is a percentage or an amount of money. The unit lives in whichever line of code happens to read the column, and nothing enforces agreement between readers.
In this system, one column is read both ways. Missing original box is a percentage for phones and a flat rupee amount for tablets. That is deliberate, it reflects how the business actually thinks about the two categories, and it is completely invisible in the schema. The fix was to make the unit itself data, in a field registry that sits above the columns:
{
key: 'no_original_box',
label: 'No Original Box',
column: 'depreciation_no_original_box',
unit: { phone: 'percent', tablet: 'rupee' },
appliesTo: 'both',
group: 'Accessories',
help: 'Percentage for phones, flat rupee amount for tablets.',
}That registry is what the admin form renders from, what the bulk upload validates against, and what the generated spreadsheet template is built from. It is a second layer of metadata that exists purely because the first layer lost the meaning when the rules moved out of code. Budget for it. If you push business rules into data and do not build something like this, the knowledge lives in whoever wrote the calculator, and it leaves when they do.
Which questions get asked is data too
This is the part people miss, and it is where the approach stops being about prices and starts being about the shape of the product.
A cellular tablet should be asked whether mobile data works. A wifi only tablet should never see that question, because the answer is meaningless and asking it makes the form look broken. An iPad should be asked about the Pencil. A cheap Android tablet should not. None of that is a code branch. Each variant carries flags:
ALTER TABLE specs
ADD COLUMN ask_network_question TINYINT(1) NOT NULL DEFAULT 0,
ADD COLUMN ask_pencil_question TINYINT(1) NOT NULL DEFAULT 0;The same flag does double duty. It drives which question the customer sees, and it guards the deduction on the server, so an answer to a question that was never asked cannot reduce someone's quote. Both the form and the calculator read the same row, which is the only reason they cannot drift apart.
Some answers skip the calculation entirely
A tablet that will not power on is not worth its base price minus a series of percentages. Nobody can assess a device they cannot switch on, so the business pays a flat salvage figure and stops. The rule is a configured price per variant, and reaching it returns immediately without touching the condition tree.
if (tabletSwitchOn === false) {
const offPrice = Number(spec.tablet_switch_off_price)
if (!Number.isFinite(offPrice) || offPrice <= 0) {
const err = new Error("Tablet switch-off final price is not configured for this model")
err.statusCode = 400
throw err
}
return { specId, basePrice, finalPrice: intRupee(offPrice) }
}The throw is the interesting line. When a rule is missing, this refuses to quote rather than falling back to something plausible. A wrong price that looks right is worse than an error, because the error gets fixed today and the wrong price gets honoured for a month. Choose loudly broken over quietly wrong wherever real money is involved.
Floors, and the numbers still living in code
Data driven rules can combine into nonsense. Enough deductions stacked on a cheap device will take a quote negative, so everything floors at zero. One case gets its own floor: a phone that cannot make calls takes its own percentage and can never be quoted below a fixed minimum, because even a phone that cannot call has salvage value and offering someone 300 rupees for it reads as an insult.
const CANNOT_CALL_MIN_PRICE = 2000
const minimumPrice = canCall === false ? CANNOT_CALL_MIN_PRICE : 0
finalPrice = Math.max(intRupee(finalPrice), minimumPrice)And here is where I should be honest rather than pretend the design is pure. Three things did not make it into data. That floor is a constant. The rule that Apple devices below 80 percent battery health take a service deduction is a string comparison on the brand name. Tablets are identified by a device type id compared against a literal 2. All three work, all three are the kind of thing that becomes a problem the day a second brand needs battery health rules or a third device type appears. A design that claims everything is data is usually a design nobody has read closely.
Let people enter what they actually know
The database stores age depreciation as a percentage, because that is what the arithmetic needs. But nobody pricing phones thinks in percentages. They think: this model is a year old, it is still worth about 22,000. Asking them to convert that into a depreciation percentage against a base price is asking them to do arithmetic in a spreadsheet cell, which means eventually somebody gets it wrong.
So the bulk upload accepts the number they actually know and converts on the way in. The sheet column says rupees, the price it is still worth. The stored value is a percentage:
// unit: age_price
// remaining rupee price -> stored as 100 - round(value / base * 100)This is worth generalising. Configuration as data only pays off if the people who own the data can enter it in their own units. Otherwise you have moved the rules out of code and into a spreadsheet that only a developer can fill in correctly, which is the worst of both.
What it costs
Two things, and the second one surprised me.
The first is the obvious one: complexity moves into the schema and into whoever maintains the admin screens. The calculator is simple, but there is now a form, a bulk uploader, a template generator, a validator and a field registry that all have to agree.
The second is accretion. Columns are cheap to add and nobody ever deletes one. The specs table has 75 columns. The quote calculator reads 21 of them. The other 49 are dead rules from earlier versions of the pricing model: separate deductions for broken screens by severity, dead spots, microphone faults, wifi faults, a battery band between 80 and 85 percent, warranty status. They still exist, they still have defaults, they still get returned by anything doing a select star, and nothing in the database says they are dead.
/**
* Deliberately a subset of the schema: `specs` has 75 columns but only the 21 in
* SPEC_DEPRECIATION_FIELDS are read by quoteCalculator.js. The other 49 are
* legacy. INSERTs let them fall to their DB defaults; UPDATEs never name them.
*/When your rules are code, a dead rule shows up as unreachable code and something eventually flags it. When your rules are columns, a dead rule looks exactly like a live one. The single most useful thing in that codebase is not the calculator, it is the explicit list of which columns are actually read, kept in one place and named.
When not to do this
If your pricing changes twice a year, a constants file is fine and this is over-engineering. The test is not how complicated the rules are, it is who needs to change them and how often. Rules that only developers ever touch belong in code, where they get types, review and version control for free.
Move them into data when the answer to who should be able to change this is somebody who does not have the repository checked out. Then commit to it properly: the units need somewhere to live, the input format needs to match how those people think, missing rules need to fail loudly, and somebody has to write down which columns still matter.
- Attach rules to the narrowest unit that has its own economics. Here that is the variant, not the model.
- Decide the order rules apply in, and document it. Compounding percentages are order dependent and the code will not warn you.
- Separate percentage rules from flat ones. Some costs scale with value and some are just the price of a charger.
- Store the unit somewhere, because the schema cannot express it and two readers of the same column will eventually disagree.
- Let flags drive which questions are asked, and guard the deduction with the same flag so the form and the server cannot drift.
- Fail loudly on a missing rule. A wrong price that looks right survives far longer than an error.
- Floor the result, and give the degenerate cases their own minimum.
- Accept the input in the units your experts think in, and convert on the way in.
- Keep an explicit list of which columns are live, because dead ones are invisible.
The payoff is the thing that does not show up in any diagram: a price correction is now somebody editing a row on a Tuesday afternoon, and the next customer gets the right number. No deploy, no developer, no ticket. That is the entire point, and everything above is what it takes to make it safe. The wider system this sits inside, including the admin panel that edits all of it, is written up in the SellPrime case study.