DEV Community

Kun Shen
Kun Shen

Posted on

Modeling a Phone Upgrade Contract as a State Machine

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
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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)