An upgrade-cost calculator should not begin with a price field. It should begin with a state.
When a user asks what an iPhone upgrade will cost, the application must know whether the user intends to return the phone, replace it with a new lease, leave early, or keep it. Those are different contract paths, not variations of one total.
Define the terminal states
A minimal model can use four terminal states:
RETURN_AT_TERM
REPEAT_UPGRADE
EARLY_RETURN
PURCHASE_DEVICE
Each state activates a different group of inputs. PURCHASE_DEVICE requires a purchase-option amount and purchase tax. RETURN_AT_TERM does not. EARLY_RETURN may require remaining scheduled payments and a condition assessment. REPEAT_UPGRADE closes one contract and begins a separate application; it should not inherit the new payment as though it were an extension of the old schedule.
Use a schedule, not an average
Do not store only monthlyPayment and termMonths. A schedule is safer:
type Money = { cents: number; currency: 'USD' };
type ScheduledPayment = {
sequence: number;
amount: Money;
dueOn?: string;
source: string;
};
This structure can represent payment changes, credits that expire, and an individual quote that does not match a public example. Integer cents prevent binary floating-point errors from leaking into consumer totals.
Model unknown as a real state
Public information cannot determine a customer’s local tax, final trade-in value, approval, condition fee, or exact Klarna purchase quote. Those values should be nullable or represented by a tagged union:
type Known<T> =
| { state: 'known'; value: T; source: string }
| { state: 'unknown'; reason: string };
Never convert unknown tax to zero. Zero is a verified value; unknown means the result is incomplete. A calculation can still return the supported subtotal plus an explicit list of unresolved fields.
Keep cost buckets separate
The calculation contract should distinguish:
type UpgradeCosts = {
scheduledLease: Known<Money>;
leaseTax: Known<Money>;
appleCare: Known<Money>;
earlyExitObligation: Known<Money>;
conditionCharge: Known<Money>;
purchaseOption: Known<Money>;
carrierCharges: Known<Money>;
};
The separation is more important than the final addition. AppleCare is optional and separate from the current Apple Upgrade lease. Carrier charges come from a wireless-service relationship, not the Klarna lease. A purchase option belongs only to the ownership path.
The published calculation methodology uses the same principle: show the inputs and boundaries instead of inventing a universal residual percentage.
Attach provenance to volatile fields
Every price or rule that can change should carry:
- source URL;
- date verified;
- device and configuration;
- whether it is a public example or an individual quote;
- source snapshot or content hash; and
- review status.
A value without provenance should not silently replace the last known-good snapshot. Freshness is useful only when the new record passes validation.
Assert exclusions in tests
State-machine tests should assert both what is included and what is excluded:
expect(returnAtTerm.purchaseOption).toBeExcluded();
expect(purchaseDevice.purchaseOption).toBeKnown();
expect(earlyReturn.remainingPayments).not.toBeAssumedZero();
expect(allPaths.carrierCharges.source).not.toBe('apple-lease');
Useful cases include an ordinary end-of-term return, three payments remaining on an early exit, ownership after partial payment credit, and a schedule whose promotional credit expires.
Return a scenario result
The result should identify its boundary:
type ScenarioResult = {
state: TerminalState;
knownTotal: Money;
includedFields: string[];
excludedFields: string[];
unresolvedFields: string[];
verifiedAt: string;
};
This lets the UI say “known return-path total before local tax” instead of presenting an unsupported universal cost.
The architecture rule is simple: choose the ending, activate the fields that belong to that ending, preserve unknowns, and show the evidence attached to every volatile input.
Primary references: Apple Upgrade and Apple’s upgrade and purchase-option guidance.
Disclosure: I maintain PhoneUpgradeCalc. This article was prepared with AI assistance and manually reviewed against the cited primary sources on August 12, 2026.``
Top comments (0)