Merit · B2C Super App · Design, reviewed

Multi-currency, and the thing underneath it

The stated blocker is that the B2C sales channel is bound to one currency. That is a symptom. This is the design for the cause, what engineering locked on 27 August, and what is still unanswered.

Owner Brian Arfi Faridhi, Product
Sessions 13 August 2026, design and sizing · 27 August 2026, market concerns alignment, 17:00 PKT
Reviewed by Zarar Tahir, Muneeb Meer, Habeeb Rahman and Ali, all Tintash
Tracked as DEC-0125 · Revised 28 Aug (v3)

What changed in v3

The 27 August alignment turned this proposal into three implementation plans, one per service. Most of the design is now locked. Three things in it were rejected, and section 00 says which. The largest is that the server no longer converts anything: Core sends a rate and the client applies it, and there are now three separate rates that must never be inverted into one another.

00What the 27 August alignment locked

Everything below this section is the proposal. This section is the answer it came back with.

On 27 August, Zarar Tahir, Muneeb Meer, Habeeb Rahman and Ali went through this document. Tintash then wrote three implementation plans, one per service, and sent them on 28 August: Ecommerce Core, Sales Channel and Storefront, and OMS. All three are scoped to Tier 1.

The spine held. Market is a real entity, currency is one field on it, and no offer has to be priced per country. Three things did not hold, and they are corrected in place below.

Locked · Ecommerce Core

1Channel is the app, market is the place. Country, both currencies, delivery fee and providers live on the market. sales_channels.currency and region are dropped as a stand-in for place.
2No offer to currency assignment gate. offer.currency stays a field. An offer on a channel is visible in every linked market except where eligibility hides it.
3The Tier 1 payment provider is POINTS. settlement_currency is that loyalty program’s currency, constrained on write. It is not hardcoded SAR, and it is not the market’s local currency.
4pricing_currency is display only. Core does not convert offer amounts. It sends exchangeRate for offer to pricing on every catalog response, and the client multiplies. No FX library on the frontend.
5Delivery fee sits on the market, denominated in settlement_currency. The Identity global SAR scalar leaves the market-aware path.
6Core does not call tax. It exposes the market country. The client taxes the settlement amount, and a missing rule fails checkout rather than charging zero.
7B2C and Storefront share one consumption path. Both fetch markets from Core, show the fee, call tax, initiate Payments.
Three things in this document were rejected

Section 05 is wrong. Points do not lose their currency. The program keeps it, and the market’s settlement currency is constrained to match. The constraint was inverted, not deleted.

Section 06 is half wrong. Nothing converts on the server. Core hands out a rate and the client applies it, and the rate that decides display is not the rate that decides the charge.

Figure 3 is wrong. A Tier 1 market has a payment provider. It is POINTS.

The rate that was one thing is now three

RatePairSupplied byUsed for
Catalogoffer.currency → pricing_currencyCore, on each offerDisplay, and nothing else
Checkout FXoffer.currency → settlement_currencyShared rate config, called by the BFFCharge, tax base, OMS, Payments, points
Programsettlement money → pointsExisting program rate endpointPoints burned, and the points shown on a tile

These are three different numbers and the plans say so explicitly. You may not invert the catalog rate to get the charge rate, and you may not apply the program rate to a displayed local price. Points are always settlement amount × program rate, never the rupiah tile multiplied by anything.

What the plans do not yet carry

Section 06 listed six things that make runtime conversion safe. The plans name a shared rate config and stop there. The spread, the per-currency rounding rule, quote pinning with an expiry, and the stale-rate policy have no owner in any of the three documents. Currency metadata is “store if cheap”, which leaves the three decimal places on a dinar unhandled. These are the review comments to send back.

Open questions Tintash put back to me

Q1Catalogue eligibility rules. The field exists on the market and the rules behind it do not. Until they are written, nothing extra is hidden, which means a Tier 1 market shows everything.
Q2Price sort, min price, and search aggregations break on a mixed-currency channel. channelMinPrices is one number per channel and stops meaning anything. Sort on the client after applying the rate, restrict the feature to offers already in settlement currency, or turn it off for Tier 1.
Q3How a client picks the market. Geo, profile country, or an explicit picker. Core will not detect it. With one live market the header can be omitted, so today’s behaviour survives until there are two.
Q4Storefront markets. A storefront created through LiveOps has no provisioning workflow for its market. The plans recommend one market per storefront so a live site never has to choose.
Q5Does the customer see both currencies, or only one? Listing and checkout may differ.
Q6Fulfilment currency. The largest one. It has its own entry in section 13.

01The symptom, and the cause

What we were told, and what I think is actually true.

The constraint as stated, 11 August

“Currency is tied to the product and sales channel. For the B2C app, only SAR is currently supported, as the platform does not support products or offers in multiple currencies.”

That is true, and it is the reason a redemption fails outright for anyone outside the riyal channel. But fixing only that leaves twelve other things unfixed, and we would rediscover them one at a time, in the order they happen to break.

The platform has no concept of a market. Country, currency, tax jurisdiction, language, phone codes, address format, payment provider and fulfilment capability are each hardcoded, configured globally, or inferred from one another. Currency is only the loudest of them.

Figure 1 · Why opening a country is thirteen changes, not one

One hardcoded place country = SA, currency = SAR tax jurisdiction phone country codes address format payment provider fulfilment capability language and direction plus store availability, legal clearance, catalogue eligibility, delivery fees, money formatting

Every arrow is something that must change to open one country, and none of them know about each other. This is the gap register, drawn.

02The proposal: Market as a real thing

Build the missing concept, and let currency be one field on it.

A market is a named, configured place we sell into. Opening a country becomes creating a record and filling in its fields, rather than a project.

Figure 2 · The Market record

countryISO 3166
pricing_currencyISO 4217
settlement_currencyISO 4217
tax_jurisdictionrule set
localelang + region
text_directionltr | rtl
allowed_phone_codeslist
address_schemanamed format
fulfilment_profiledigital | physical
payment_providerslist
catalogue_eligibilityrules
money_configMoney, scoped
distribution_statestore availability
lifecycle_statedraft → cleared → live

Fourteen fields. Thirteen of them are a row on the gap register that currently has no home in the platform.

03Tier 1 and Tier 2 stop being two programmes

This is the part I care most about, because it changes what expansion costs.

We currently maintain two definitions of “open a country.” Tier 1 is cheap and configuration-shaped: the app usable in-country, funded by points, digital delivery only. Tier 2 is a real market launch with local payment and physical goods. They are separate programmes today because only Tier 2 has a definition in the platform.

Under this design both are Market records, and both display in local currency. Currency stops being what separates them. What separates them is fulfilment and the payment rail. This survived the 27 August review intact, and it is locked decision 1.

Figure 3 · Same entity, different completeness

Tier 1 market

country = ID
pricing_currency = IDR
settlement_currency = SAR
fulfilment_profile = digital
payment_providers = [POINTS]

Shown in rupiah, charged in the program’s currency, delivered digitally. There is no card rail to integrate, but there is a provider: POINTS. Settlement is SAR here because the loyalty program is SAR, not because Indonesia is.

Tier 2 market

country = JO
pricing_currency = JOD
settlement_currency = JOD
fulfilment_profile = physical
payment_providers = [POINTS, psp_b]

Same record, more fields filled in, plus a cash rail and physical goods. One warning from the Core plan: a single settlement_currency per market only holds while the only rail is POINTS. A card rail in a second currency cannot share that field, so Tier 2 will need this modelled again.

The cheap half is still the half that matters. A market that displays locally, funds redemption with points and delivers digitally needs the Market entity, a rate on the offer, and nothing else. Card rails and localisation only arrive when there is a cash leg or a full local launch, and both are additions to the same record rather than a second programme.

04Three currencies, kept separate

Most multi-currency defects come from collapsing three different things into one word.

Figure 4 · One authored amount, two rates, three roles

Offer authored once, never rewritten offer.currency Pricing what the customer reads on the tile display only, never charged Settlement what is actually charged and taxed the loyalty program’s currency Points burned settlement × program rate catalog rate checkout FX program

Two rates leave the same authored amount, and they are not each other’s inverse. Core supplies the catalog rate on every offer; the checkout rate is fetched separately from the shared rate config by the BFF. The rule the plans state twice, because it is the defect waiting to happen: never invert the display rate to get the charge, and never multiply a displayed local price by the program rate.

Settlement is not the local currency. An Indonesian Tier 1 market shows rupiah and settles in riyals, because settlement is constrained to match the loyalty program. That is a real change from what this section said in v2, and it is why reconciliation still lands in one currency without anyone designing for it.

05Points keep their currency, and it defines settlement

This section proposed deleting a field. The answer was to promote it instead.

Today a loyalty program carries a currency, and a redemption fails when that currency does not match the sales channel. That is the mechanism that would block every cohort abroad. v2 argued the field should be removed, on the grounds that a balance is a count and a count is not denominated in anything.

The implementation plans do the opposite, and on reflection they are right. The program keeps its currency. The market’s settlement_currency is then constrained on write to match the attached POINTS program, so the two can never disagree. That is the same guarantee the deletion was reaching for, bought without touching loyalty at all.

Figure 5 · What actually changed

Loyalty program balance: 12,400 pts currency: SAR Sales channel order total: IDR 716,000 currency: IDR must be equal redemption fails Opening a currency means migrating or duplicating the program.
Loyalty program balance: 12,400 pts currency: SAR Market ID pricing = IDR settlement = SAR constrained on write Order, any market shows IDR 716,000 charges SAR 179, burns 4,475 pts One program funds redemptions in every market it is attached to.

Nothing in loyalty changes. The market is the thing that has to agree, and it is a write constraint rather than a migration. Changing a program without updating that market’s delivery fee is rejected, because the fee would then be denominated in the wrong unit.

The arithmetic that must not be got wrong

Points are settlement amount × program rate, always. The settlement amount is the authored offer amount converted with the checkout rate, or the amount itself when the currencies already match.

Points are never the displayed local price multiplied by anything. Taking the rupiah tile and running the SAR program rate over it produces a number roughly four thousand times too large, and it will look like a working feature until somebody reconciles a burn.

What this costs, and it is worth saying out loud: a market cannot be opened against a program in a different currency. Two programs in two currencies serving one country needs two markets, and the Core plan lists market uniqueness as an unresolved ops question for exactly that reason.

06Nothing converts on the server

An offer carries one authored price and keeps it. Core attaches a rate to the offer, and the client multiplies.

Corrected 28 August

v2 had the platform convert at read time and hand the customer a local number. The plans do not do that. Core never rewrites an offer amount. It returns the amount as authored, with exchangeRate for offer to pricing alongside it, and the client renders amount × rate. The stated reason is that no FX library should exist on the frontend, and the effect is that the read path stays honest about what was actually authored. The goal below is unchanged and it is what won. The mechanism under it moved.

The alternative is a price authored per currency. I proposed that first and it is the wrong call, for a reason worth stating plainly.

Why authored pricing fails the actual goal

Authoring a price per currency makes selling into a country a conscious act by whoever owns the catalogue. Coverage then depends on somebody having done that work, for that currency, for that offer. That is the same bottleneck we have today wearing different clothes, and it is the opposite of the goal, which is to open countries with less effort rather than more.

That argument was accepted and is locked decision 2: the currency gate on offer assignment is removed, and an offer is visible in every linked market. Every offer is available in every market the day the market record is created, with no catalogue work at all.

The cost is unchanged. A converted price is not a chosen price, and FX now sits on the path that takes a customer’s money. Six things make that safe. Two of them are in the plans. Four have no owner in any of the three documents, and they are marked below.

Figure 6 · The conversion pipeline

Rate feed · daily, or 4× a day cached base price SAR 179.00 cached rate plus a per-currency spread rounding rule 43.71 → 44.90 shown € 44.90 the rate is pinned to the quote, then stored on the order

Nothing in the customer’s path calls the rate provider. Checkout reads a cache, so the provider being slow or down cannot take checkout with it. Read this diagram twice now: it runs once for display and once again, with a different target currency, for the charge. The plans call the shared thing at the top a “shared rate config” and do not otherwise specify it.

1 · Refresh on a schedule, not per request  COVERED

Rates are pulled daily, or four times a day for tighter tracking, and cached. Checkout reads the cache.

2 · Pin the rate to the quote  NOT IN THE PLANS

When a cart or checkout quote is created the rate is stamped onto it with a short expiry, so the price shown is the price charged even if a refresh lands mid-session.

3 · Store the rate on the order  COVERED

Refunds, partial refunds, reconciliation and disputes resolve against the rate that was applied, not against whatever it is when someone asks. The OMS plan carries this, and adds one the proposal missed: displayExchangeRate is DECIMAL(10,2) today, which rounds an FX rate away before it is stored. It has to widen.

4 · Apply a spread  NOT IN THE PLANS

Convert at mid-market plus a configurable buffer per currency, so ordinary movement between the refresh and settlement does not come out of margin.

5 · Round to a rule, per currency  NOT IN THE PLANS

Raw conversion produces 43.71. A rule (nearest 0.05, nearest whole unit, .90 endings) makes it a number that looks chosen. This is the difference between a converted catalogue reading as deliberate and reading as automated.

6 · Define the stale-rate behaviour  NOT IN THE PLANS

With a daily refresh a rate is always somewhat old, so “stale” needs a number. Past the threshold: serve last known good with an alert, or close the market. Last known good is the right default, because a market going dark over a feed hiccup is worse than a slightly old rate.

The exception became the default

Some products are natively denominated. A EUR 50 gift card has a face value of EUR 50 and must never become a converted riyal figure. v2 handled this as a special override. It no longer needs to be one: because Core never rewrites an amount, every offer keeps its authored currency by default, and the face value survives on the read path with a display rate hung next to it.

That solves it in the catalogue and moves the whole problem to fulfilment, where it is now the single largest open item in the three plans. See section 13.

07Tax takes currency as an input

Today the service receives a country code and infers the currency from it.

That inference holds only while every market is served in its own local currency. It breaks on the first Tier 1 market, where the country is not Saudi Arabia but the money charged is still riyals.

Locked, with the caller moved

Core does not call tax at all. It exposes the market’s country, and Sales Channel or Storefront calls the tax service on the settlement amount, then puts a real taxAmount on the OMS line items. One addition the plans made and this document did not: a missing rule for that country and category fails checkout with TAX_RULE_NOT_FOUND. It does not silently charge zero, which is what happens today.

Figure 6 · The contract change

country_code, price, category currency is absent infers currency from country wrong for any Tier 1 market
market, Money, category currency travels with the amount country selects the rules currency is never guessed
Order of operations, and it should be written down

Convert, then tax. Tax is calculated on the settlement amount, never on the authored amount and never on the displayed one. Calculating tax on the authored price and converting the result produces totals that will not reconcile and will drift by rounding on every line. The rounding step still belongs here, and it is one of the four things with no owner in the plans.

Two related fixes belong in the same change:

08The rest of the design

Less interesting, and the parts most likely to be skipped.

Money is never a bare number, and here is what skipping it costs

Every monetary value becomes a Money: an amount and an ISO 4217 currency, together, always. This is the item most likely to be waved through, so it is worth showing the failure rather than asserting the principle.

Worked example · using configuration that exists today

Delivery fee lives in Identity global config as a bare number, merit-merchandise-delivery-fee. Say it holds 15, and everyone understands that to mean riyals. Now open a market in Indonesia. A product at SAR 179 converts at read time to rupiah. Illustrative rate, 1 SAR to 4,000 IDR:

price = 716,000 already IDR, converted delivery fee = 15 still SAR, from global config order total = 716,015 no error, ships fine

The correct total is 776,000. Every order with delivery undercharges by roughly 60,000 rupiah, and nothing raises a flag, because arithmetically the system did exactly what it was asked. It surfaces weeks later as an unexplained reconciliation gap.

With a currency attached, IDR 716,000 + SAR 15 fails the first time a developer runs it. It never reaches production.

That is the whole argument, and it is not about tidiness. It converts a silent wrong number into a loud crash. A loud bug is cheap because it is found in seconds. A silent one is expensive because by the time it is found, nobody knows how many orders it touched.

Double conversion, or none at all

An order crosses catalogue, cart, OMS and the fulfilment payload. A bare number carries no way for the receiving service to know whether it has already been converted, so it can only infer from a variable name. Convert twice and 179 becomes 2.8 billion. Convert never and the customer pays 179 rupiah for a 179 riyal item.

Refunds resolve against nothing

An order paid in rupiah on Monday, refunded on Friday, arrives at the refund service as a number with no currency and no rate. The amount returned can differ from the amount charged, and that is not a technical defect any more, it is money.

The cheaper alternative, which deserves a real answer on Thursday

Keep everything internal in the base currency and convert only at the very edge, in the response layer, never persisting or passing the converted value. If that holds, bare numbers stay safe, because only one currency ever exists inside the system, and this phase shrinks a lot.

The test is one question: does a converted value ever leave the presentation layer? Three places suggest it does. Tax is calculated on the converted and rounded local amount, so it enters the tax service and comes back. The order has to store what the customer agreed to pay in their own currency, for receipts, refunds and disputes. Settlement happens in the local currency at the provider. Once a converted value is persisted and passed between services, two currencies coexist and bare numbers stop being safe.

So the recommendation is strict at the boundaries, relaxed inside them

Currency is mandatory wherever money is persisted, crosses a service, or enters an API. Inside a single function that demonstrably handles one currency, it does not need to be. That buys most of the protection for a fraction of the cost, and it lets this phase be sized honestly rather than as “change every money field in the platform.”

The remaining pieces

Money-valued config is market-scoped

Delivery fees and every other money-valued setting move from global scalars to market-scoped Money. A global number applied in a currency it was never denominated in is a wrong charge that raises nothing.

Currency behaviour is data, not code

One metadata table: ISO code, minor units, symbol, position, grouping, and the rounding rule from section 06. Dinars carry three decimal places and riyals carry two. Any code assuming two is a defect waiting for the first Jordanian order, and Jordan is the case most likely to be attempted early.

Catalogue availability is per market

An offer is available in a market or it is not, by rule rather than by a hand-picked list per launch. Two things are modelled separately because they fail differently: whether an item can be issued to someone there, and whether it can be redeemed there.

Payment provider is selected, not assumed

Routing becomes a strategy keyed on market, currency and method. Adding a provider to cover a currency the incumbent cannot process becomes configuration and an integration, rather than a change to checkout.

Migration: backfill, once

Every existing offer, order and order item is in riyals with nothing recorded. Backfill SAR explicitly. Leaving old rows implicit saves a one-time migration and buys a permanent two-mode read path that every future engineer has to know about.

09What this closes

The thirteen gaps on the multi-country register, and what in this design answers each.

9

closed by the design, as engineering work

4

not engineering problems. They gain a field, so the blocker is visible rather than remembered

1

entity carries all thirteen: Market

GapWhat it isAnswered byKind
G1Sales channel bound to one currencyMarket pricing_currency for display and settlement_currency for the charge, the assignment gate removed, rates attached to the offer rather than applied to itBuilt
G2Tax infers currency from countryCountry from the market, tax on the settlement amount, called by the client. A missing rule fails checkoutBuilt
G3RTL not supportedlocale and text_direction on MarketBuilt
G4App Store country availabilitydistribution_state. Still an external actionVisible
G5Play Console country availabilitydistribution_state. Still an external actionVisible
G6Phone auth country enablementallowed_phone_codes on MarketBuilt
G7Which items are redeemable abroadPer-market eligibility, issue and redeem modelled apartBuilt
G8Per-country legal clearancelifecycle_state gate before a market goes liveVisible
G9Payment rails per countryProvider selection by market and currencyBuilt
G10Incumbent cannot process every currencySame. Adding a provider stops being a checkout changeBuilt
G11Physical fulfilment outside GCCfulfilment_profile, enforced through eligibilityVisible
G12Address formats outside KSAaddress_schema on MarketBuilt
G13Money-valued global configMarket-scoped MoneyBuilt

G4, G5, G8 and G11 are not engineering problems and this design does not pretend to solve them. What it does is give each one a field, so what is blocking a market is a value someone can read rather than something someone has to remember.

10Build order

The six phases became sixteen, across three services, with a hard dependency chain between them.

Replaced 28 August

v2 sequenced this as one programme with Money hygiene first. The plans do not do that. There is no global Money migration; currency is attached at three named boundaries instead. Core ships first, Sales Channel and Storefront consume it, OMS records what was charged. Nothing in OMS can be tested before the checkout path exists.

Figure 7 · Three tracks, in dependency order

Ecommerce Core · the spine, blocks everything else

A
Schema and admin

markets, sales_channel_markets, CRUD, and Market SA bootstrapped onto merit-b2c. Done when LiveOps can create and link a market.

B
Stop the currency gate

Assignment, ingest and product-id helpers. Done when a non-SAR offer can link to merit-b2c. This is the smallest change in the whole programme and it is the one that unblocks the original complaint.

C
Channel market APIs

GET /channel/markets, x-market-id on catalog reads, fee from the market, and the offer to pricing rate on every price response.

D
Deprecate channel place and money

Stop reading sales_channels.currency and region.

E
Eligibility and sort

Blocked on open questions 1 and 2. Until eligibility rules exist, a market shows everything the channel shows.

Sales Channel and Storefront · the consumption path

A
Market fetch

Hold marketId, pass it on catalog, render the fee from the market rather than from Identity.

B
Display

Apply Core’s rate. No FX library on the frontend.

C
Checkout settlement

The real work. The BFF calls the shared rate config for offer to settlement, adds the market fee, and persists that rate. The catalog rate is not used for money.

D
Tax

Tax on the settlement amount with the market country. Fails closed when no rule exists.

E
Payments and OMS

Split display from settlement on the order. POINTS allocated against the settlement total.

F
Provision

A new storefront links one market at creation, so a live site never has to choose one.

OMS · records what was charged

A
Contract

Accept a split display and settlement payload. An IDR-display, SAR-settlement order creates without forcing the two to be equal.

B
Amounts

Line money is settlement only. Tax and fee become real figures instead of hardcoded zero.

C
Allocations

The POINTS leg requires its rate, and the leg’s settlement must equal the order total.

D
Fulfilment

Blocked. See section 13. Do not point fulfilment at settlement alone until this is answered.

E
Rate precision

Widen displayExchangeRate past two decimal places. Cheap, and wrong data until it is done.

Bar length is scope, not time. Still no estimate against any of it, and section 12 says where that stands.

Core A plus B plus C is still the smallest slice with a business outcome, and it is now a smaller slice than v2 described, because the conversion engine that used to sit in phase C moved to the client. What it opens on its own is a market that displays locally. Charging in it needs the Sales Channel track through C, and settling the order needs OMS through B.

11What opening a country looks like afterwards

Shown for a Tier 1 market, which is the common case.

1
Create the Market
Country, pricing currency, locale, phone codes, address schema, fulfilment profile.
2
Confirm the currency has a rate on the feed
Then set its rounding rule and its spread. Two configuration values, not a build.
3
Confirm catalogue eligibility resolves to a non-empty set
A query, not a research exercise repeated per launch.
5
Move lifecycle to legal_cleared, then live
The gate is a field. A market cannot go live with it unresolved.

No catalogue work. Every offer is priced in the new currency the moment the market exists, because conversion happens at read time rather than being something a person does per offer. That is the whole point of section 06.

12Sizing

Sixteen phases, three services, and still not one number against any of them.

Status on 28 August

This table was empty on 13 August because the session had not happened. It is still empty fifteen days later. Khawar Rasheed confirmed on 27 August that the build has not started, no estimate has been produced, and the earliest start is the week of 31 August. The implementation plans arrived on 28 August with phases but no durations.

Two watchdog records cover exactly this and neither has ever been chased: the component estimate and architecture diagram, and whether the first week of September still holds. It does not.

TrackPhasesEstimateAssumption it rests on
Ecommerce CoreA to E
Sales Channel and StorefrontA to F
OMSA to E
Shared rate configunscoped

The fourth row is not in any of the three plans. All three reference a “shared rate config” that Core and the BFF both call, and none of them owns building it.

Three answers still outstanding, now sharper than they were on 13 August:

13Where this is most likely wrong

Written by product, not engineering. Engineering has now reviewed it, so this list has changed: two of these are confirmed, one is answered, and one is new and larger than anything that was here before.

New, and the largest open item in the three plans

Fulfilment still expects the offer’s own currency, and checkout now speaks settlement. Merit fulfilment takes merchandise at unit_price.currency in the catalogue currency, and a gift card denomination is a face value with no currency field on the request at all. Send a settlement amount into that and gift cards are issued at the wrong face value and merchandise totals stop matching MGC.

Two ways out, both with a cost. Convert settlement back to the offer currency at fulfil time using the stored rate, and accept that the reverse conversion may not invert cleanly: charge SAR 27.13, issue EGP 100, reconcile the difference forever. Or change the Merit and MGC gift card and merchandise APIs to take a settlement currency, which is the clean answer and a downstream change to services outside this programme.

Until this is locked, OMS phase D cannot ship. This is the one to answer first, because the second option has a lead time that nothing else here has.

Selling a natively denominated product at a converted price  ANSWERED IN THE CATALOGUE

v2 needed a flag on the product so a EUR 50 card was not sold at a converted riyal figure. That flag is no longer needed: an offer keeps its authored currency all the way through the read path, so the face value is never overwritten. The same problem then reappears at fulfilment, in a worse form, and it is the callout at the top of this section.

Margin leaks quietly if the spread is never reviewed  CONFIRMED, NO OWNER

The buffer needs an owner on the commercial side. Set once and forgotten, it is either giving away margin or overcharging a market, and neither shows up as an error anywhere.

Converted prices move

A product at 43.70 one week and 44.05 the next. Rounding rules soften this and do not remove it. If a market we care about has a regulatory or commercial expectation of price stability, that market needs authored prices and this design has to accommodate it.

The Market entity may be too large a swing  SETTLED

If binding currency to the existing sales channel and opening a channel per market gets us most of the way for a fraction of the cost, I would rather hear that than defend the model. The test is whether a channel per market makes the other twelve gaps easier or harder.

Phase A touches everything  NO LONGER APPLIES

A change to every money field in the platform is the kind of migration that is either done properly once or half-done forever.

The rate feed itself is unspecified  STILL OPEN, NOW WORSE

Which provider, what it costs, and what its own availability record looks like are all still open. All three plans now call it “the shared rate config” and treat it as something that already exists. It does not, and two services depend on it returning the same answer.

What this design deliberately does not do

Limits. Engineering has reviewed the design and written implementation plans against it, so the model is no longer only a proposal. The estimates are still missing, and section 12 says so plainly. Section 09 claims thirteen gaps closed and still closes them on paper: nothing in this programme has been built. The three plans are all marked Draft and scoped to Tier 1, so anything this document says about Tier 2 remains proposal only.

Written for the new platform only. Nothing here inherits from, or stays compatible with, the old stack.