Merit · B2C Super App · Design, reviewed
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.
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.
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
| 1 | Channel 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. |
| 2 | No 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. |
| 3 | The 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. |
| 4 | pricing_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. |
| 5 | Delivery fee sits on the market, denominated in settlement_currency. The Identity global SAR scalar leaves the market-aware path. |
| 6 | Core 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. |
| 7 | B2C and Storefront share one consumption path. Both fetch markets from Core, show the fee, call tax, initiate Payments. |
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
| Rate | Pair | Supplied by | Used for |
|---|---|---|---|
| Catalog | offer.currency → pricing_currency | Core, on each offer | Display, and nothing else |
| Checkout FX | offer.currency → settlement_currency | Shared rate config, called by the BFF | Charge, tax base, OMS, Payments, points |
| Program | settlement money → points | Existing program rate endpoint | Points 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.
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
| Q1 | Catalogue 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. |
| Q2 | Price 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. |
| Q3 | How 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. |
| Q4 | Storefront 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. |
| Q5 | Does the customer see both currencies, or only one? Listing and checkout may differ. |
| Q6 | Fulfilment currency. The largest one. It has its own entry in section 13. |
What we were told, and what I think is actually true.
“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
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.
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
Fourteen fields. Thirteen of them are a row on the gap register that currently has no home in the platform.
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
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
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.
Most multi-currency defects come from collapsing three different things into one word.
Figure 4 · One authored amount, two rates, three roles
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.
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
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.
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.
An offer carries one authored price and keeps it. Core attaches a rate to the offer, and the client multiplies.
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.
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
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.
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.
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.
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
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:
Less interesting, and the parts most likely to be skipped.
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:
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.
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.
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.”
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.
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
| Gap | What it is | Answered by | Kind |
|---|---|---|---|
| G1 | Sales channel bound to one currency | Market pricing_currency for display and settlement_currency for the charge, the assignment gate removed, rates attached to the offer rather than applied to it | Built |
| G2 | Tax infers currency from country | Country from the market, tax on the settlement amount, called by the client. A missing rule fails checkout | Built |
| G3 | RTL not supported | locale and text_direction on Market | Built |
| G4 | App Store country availability | distribution_state. Still an external action | Visible |
| G5 | Play Console country availability | distribution_state. Still an external action | Visible |
| G6 | Phone auth country enablement | allowed_phone_codes on Market | Built |
| G7 | Which items are redeemable abroad | Per-market eligibility, issue and redeem modelled apart | Built |
| G8 | Per-country legal clearance | lifecycle_state gate before a market goes live | Visible |
| G9 | Payment rails per country | Provider selection by market and currency | Built |
| G10 | Incumbent cannot process every currency | Same. Adding a provider stops being a checkout change | Built |
| G11 | Physical fulfilment outside GCC | fulfilment_profile, enforced through eligibility | Visible |
| G12 | Address formats outside KSA | address_schema on Market | Built |
| G13 | Money-valued global config | Market-scoped Money | Built |
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.
The six phases became sixteen, across three services, with a hard dependency chain between them.
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
Sales Channel and Storefront · the consumption path
OMS · records what was charged
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.
Shown for a Tier 1 market, which is the common case.
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.
Sixteen phases, three services, and still not one number against any of them.
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.
| Track | Phases | Estimate | Assumption it rests on |
|---|---|---|---|
| Ecommerce Core | A to E | ||
| Sales Channel and Storefront | A to F | ||
| OMS | A to E | ||
| Shared rate config | unscoped |
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:
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.
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.
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.