ASL Buy Flow v1

Draft 1 · 14 September 2026 Owner Omar (product) For Seif ElShafey (backend) · Ibrahim (business) · Seif Gad (compliance)

§0How to read this

Everyone

This is one flow, a customer buying bars and having ASL store them, written three ways on one page, because the three of you need three different things from it.

You areReadSkip
Ibrahim§1 the picture · §2 where the money is · §6 decisions§4, §5
Seif ElShafey§3 screen by screen · §4 data · §5 where your backend and the product disagree · §6§2
Seif Gad§2 (tax lines) · §6 (anything marked Compliance)the rest

Sell, Vaults and Collection get their own pages in the same shape once this one is agreed. Buy is first because it is the door into vaulting, and because Seif's backend is already built around it.

Sources: docs/PRODUCT.md §4–5 (12 Sept founder review) · product-coherence/DECISIONS.md · pricing-study/EGYPT-CHECKOUT-RECOMMENDATION.md · Asl_Business_Model.pdf · the ASL_Backend repository at main (Pricing, Trading, Payments, Ledger modules; HANDOFF.md §34–35).

§1The flow in one picture

Everyone
HomeHoldings · Buy
Choose barsmetal · size · qty
CartEstimated total
Review purchaseFirm quote · no timer
PayOrder placed · bars reserved
Checking your paymentfixed amount · View order
Completedbars owned · serials · record
from Review purchase, on return or at Pay
The price has changedreview the updated total · everything kept · nothing charged
from Pay, before payment starts
Not available"Nothing was charged" · cart kept
from Checking your payment
Failednothing owned · refund status · Try again

Final price before you pay. Fixed once payment starts.

That sentence sits on Cart and on Review. “Payment starts” means the server accepted that exact quote and opened a payment attempt. A tap or a spinner on its own does not count.

Three different things happen at three different moments, and every screen has to know which one it is on:

MomentWhat it isMoneyMetal
EstimateToday's price for what's in the cartNothing chargedNothing reserved
QuoteA firm price for this exact cart. Checked again before any payment is acceptedNothing chargedNothing reserved (see M3)
OrderThe quote accepted and a payment attempt openedPayment in progress at the fixed amountReserved for this order
CompletedPayment confirmed and bars allocatedPaidOwned, with serials, record issued

No timer on the Buy screen. The customer chooses bars and reviews the total without a clock. ASL checks that the quote and the stock are still valid before it accepts payment. If the price needs updating, everything is preserved and the customer is asked to approve the new total; a changed amount is never charged silently. Once payment is accepted, the amount stays fixed while payment completes, even if the customer leaves the app for InstaPay or a mobile wallet.

This is a reasoned proposal to validate with customers rather than a claim that research has proved it superior. Two things to check in testing: whether people understand when their price becomes fixed, and whether a refresh surprises them.

§2Where the money is

IbrahimSeif Gad

Launch revenue is the buy-side markup and the sell-side markup (business model §1–2). Everything else in the business model is post-launch. This is what a customer pays at each step and which part of it is ASL's.

StepWhat the customer seesMade ofASL's revenueWho decides the number
Choose bars / ReviewBar price, one inclusive number per barMetal value + premium. Premium = supplier's premium (manufacturing, etc.) + ASL markupThe ASL markup. Business model: never below EGP 50 per incrementIbrahim (amount, per metal) · Ibrahim + Seif E. (basis, D2)
Cart / ReviewPayment fee, if any, shown at the method choicePaymob card cost, InstaPay costNone, unless it is rounded upIbrahim: pass through, absorb, or fold into the premium (D5)
ReviewVAT, if any, as an included or added lineTax on premium / manufacturing? On the metal?NoneSeif Gad (D6)
StorageNothing at launchNone at launch. Business model lists 0.25 %/yr as a future streamIbrahim, to reconfirm “free storage” is the launch promise (D7)
Vault (label)NothingNone. No fee to create, move or dissolve a vaultSettled (PRODUCT §11)
Later: SellEGP 50 per increment deductedSell-side markupNext page
Later: Collection“Not yet quoted” until ops has a fee and a windowPossibly a collection feeNext page

Not launch revenue, and not on any screen: subscription (PRODUCT §10: no Year 1 subscription), storage fee, in-house spread, partnerships, lending, card transaction fees.

The one business question that changes the screens: whether “metal value” on Price details is the public spot price or the supplier's trading price. The customer's total is identical either way; only the explanation line differs. See D2.

§3Screen by screen

Seif ElShafeyOmar

Each row is one screen state. “Backend gives / does” is what the app needs to render it and the side effect. The last column is the failure that screen must handle.

#ScreenCustomer seesCustomer canBackend gives / doesWhat can go wrong here
1HomeHoldings per metal, estimated EGP value, BuyTap Buy; or open a vault and tap Buy there (that vault becomes the destination label)GET /holdings, GET /prices (indicative)Prices stale → show “Price as of HH:MM”, still allow Buy; the quote step refuses if truly stale
1aGateKYC status / “You can't buy yet”Complete KYCcan_transact on the profileNot verified → no Buy. Never a fake success
2Choose barsMetal tabs (gold, silver, copper if enabled). For each: the sizes on the ladder, purity, inclusive unit price, availability, an integer stepperPick metal, size, quantityGET /catalog: SKU ladder with purity, weight, unit_price (inclusive), components[], available, observed_at, statusAvailability unknown (provider down) → “We couldn't check availability”, never “Sold out”
3CartLines (one per SKU × qty), Estimated total, payment method picker with its fee shown at the choice, destination (vault name or “Stored with ASL”), the rule sentence, Review final priceEdit qty, remove line, change method, change destinationNothing new. Computed from §2 prices. v1: one line per order, see M1Empty cart → button disabled
4Review purchaseBars, payment method, destination, Total EGP X, Price details (metal value / premium / payment fee / VAT), Pay EGP X. No timer, no held-until timeOpen Price details, open Help, switch language (keeps the quote), tap PayPOST /quotes {sku_id, quantity, payment_rail} → signed quote token + readable fields: quote_id, unit_price, total, components[], observed_at, expires_atProvider stale → “Price unavailable right now”, cart kept. Nothing on this screen counts down.
4xThe price has changedSame screen, bars still selected, and a calm line: “The price has changed. Review your updated total.” Old total → new total, plainly, in either directionApprove the new total (a fresh Pay tap), or leaveReached two ways: the app re-quotes quietly when the customer returns after an interruption and the quote has lapsed, or the server refuses a lapsed quote at Pay. Either way POST /quotes again. If the total is unchanged, update silently and stay on 4Never charge a changed amount without approval. The app uses expires_at only to know when to re-quote; it never shows it. The server's check at Pay is the enforcement
5PayThe payment surface for the chosen rail: card → Paymob hosted checkout in an in-app browser sheet; InstaPay → ASL's account details + reference + “I've sent it”. If the rail has a genuine deadline for completing the transfer, it is explained here on the instructions, and nowhere near the Buy screenComplete payment, or cancelPOST /orders {quote_token, payment_method, vault_id?} with Idempotency-Keyorder_id, status, payment_session {checkout_url, attempt_ref}. Backend: verify + consume quote, reserve inventory, create order, open Paymob intention. One transaction plus the PSP call. From this point the amount is fixed while payment completes, even if the customer leaves the app for InstaPay or a mobile walletQuote lapsed at the server → 4x, nothing charged. Inventory short → “Gold 1 g is no longer available. Nothing was charged.” cart kept. Rail refuses the amount (per-transaction limit) → choose another method, nothing charged. Duplicate tap / lost response → same order_id comes back instead of a second order
6Checking your payment“Checking your payment”, the fixed amount, order reference, View order; Home shows a pending rowLeave and come back; nothing elseGET /orders/{id} polled: awaiting_payment → paid → allocated → completed, or failedAn uncertain outcome must never say “Nothing was charged”. Never reprice. Never retry the charge from the app
7CompletedBars with serials, total paid, date, destination vault, ownership record ID, share/printView record, go to vaultGET /orders/{id} now carries pieces[] {serial, weight, purity} and record_id; GET /holdings updated
7xFailedPlain reason (declined, timed out, price guard tripped), “Nothing is owned”, refund status if money moved, Try again → back to 3Retry from the cartOrder failed with reason and refund_statusThe internal price guard (PAY15) fails the order and refunds; the customer is never asked to accept a new number after Pay

Copy on 3 and 4 in both languages is in product-coherence/DECISIONS.md: Estimated total / الإجمالي التقديري · Review final price / مراجعة السعر النهائي · The price has changed. Review your updated total. / تغيّر السعر. راجع الإجمالي المحدّث. (Arabic draft, needs native review) · Checking your payment / جارٍ التحقق من الدفع · Nothing was charged / لم يُخصم أي مبلغ.

§4The data the app needs

Seif ElShafey

Field names are the product's; where the backend already has a name for the same thing it is in the last column, so nobody renames anything on either side by accident. Amounts are EGP in piastres (integers), weights in milligrams (integers), matching Piastres and Milligrams in the shared kernel.

4.1 Catalog and indicative prices: GET /catalog

FieldMeaningBackend today
sku_idOne bar product: metal + sizenone. Quotes are Asset + Milligrams; see M1 / M4
metalgold / silver / copperAssetCode
weight_mg, purity1 000 mg, 999.9fixed lot per asset in Asset
unit_priceInclusive price for one barUnitPrice
components[]{kind, amount}, see 4.4PriceComponent {Kind, Amount}
availabletrue / false / unknownnone; a firm-inventory balance exists in Ledger
observed_at, statuswhen the base price was read; fresh / staleObservedAt; Price.IsFreshAt
enabledmetal on sale at all (the copper gate)

4.2 Firm quote: POST /quotes

Request: {sku_id, quantity, payment_rail}. Response: the signed token plus the same fields decoded, so the app renders without parsing a JWT.

FieldMeaningBackend today
quote_tokenOpaque, signed, single-use, bound to this customerQuoteToken
quote_idShown on the order laterQuoteId (QuoteReference)
quantity, unit_price, totalFor the review screenQuantity, UnitPrice, Total
components[]Price detailsComponents
observed_at“Price as of”ObservedAt
expires_atAbsolute UTC instant. Never shown to the customer. The app uses it to decide when to re-quote on return; the server enforces it at PayExpiresAt. AgDR-C-0119 already made it an absolute value, which is correct
payment_feeFee for the chosen rail, if charged separatelynone, see M5

4.3 Order: POST /orders, GET /orders/{id}

Request: {quote_token, payment_method: {rail, saved_id?}, vault_id?} + header Idempotency-Key.

FieldMeaningBackend today
order_idReference on every later screen and on the slipOrderId
statusawaiting_payment · paid · allocated · completed · failed · cancelledOrderStatus. Drop Initiated from the wire; the app never sees it
totalFixed at placement; never changes afterQuotedTerms.Total
payment_session{checkout_url, attempt_ref} for card; for InstaPay: ASL account, reference, amountPaymentSession(CheckoutUrl, AttemptReference)
vault_idDestination label. Settlement intent only; no money effectnone, see M8
pieces[]{serial, weight_mg, purity} once allocatedLedger allocation; not yet
record_idOwnership record, once completednot yet
failure {reason, refund_status}For screen 7xnot yet

4.4 Price components: the list to fix now

Because PriceComponentKind is on the wire as an integer, adding kinds later is fine but reordering is a silent break (AgDR-C-0115). So decide the whole list now, even if some are always zero at launch. Components must sum to total; PricedTerms already enforces this.

KindLabel on Price details (EN / AR)Notes
MetalValueGold price / سعر الذهب (per metal)Reference × weight. D2 decides which reference
PremiumBar premium / علاوة السبيكة (Omar to confirm against the Egypt terminology note)Supplier premium + ASL markup, one line. Never labelled “fee”. Labelled “manufacturing” only if that is all it is
PaymentFeePayment fee / رسوم الدفعOnly if D5 = pass through. Shown at the method choice too
TaxVAT / ضريبة القيمة المضافةOnly if D6 says so. Shown as included or added per the invoice rule

§5Where the backend and the product disagree today

Seif ElShafeyOmarIbrahim

These are not bugs on either side. They are the places where the app was designed against one model and the backend was built against another, and nobody had both in front of them. Each has a recommendation so it can be closed in a meeting rather than debated by message.

#The product saysThe backend hasRecommendation for v1Decides
M1
Cart shape
Mixed cart: gold + silver + copper in one purchase, all-or-nothing (PRODUCT §5)One quote = one asset × one quantity (MintQuoteCommand); one order = one quoteOne SKU line per order at launch. A mixed cart is several orders paid one after another. Defer multi-line all-or-nothing: it is the single hardest thing on the backend list, and the app already builds per-metal stacks that can submit separatelyOmar + Ibrahim
M2
Quote lifetime
No timer on the review screen. The quote is re-checked when the customer returns and again at Pay; a changed total is shown for approvalConfigured 5–30 s bounds (PricingOptions.QuoteOptions); the research in HANDOFF.md §34 found 5–10 min elsewhere120 seconds, widen the config bounds. The customer never sees the clock: the app re-quotes on return, the server re-checks at Pay, and a changed total is shown for approval. The firm carries the exposure (PAY15), so Ibrahim signs the numberIbrahim (exposure)
Seif E. (bounds)
M3
When bars are reserved
At Review final price: the quote reserves every pieceAt Pay: PlaceBuyOrder reserves inside the order transaction; the quote is price-onlyAccept the backend. Quote = price only; reservation at Pay. Add the “no longer available, nothing charged” state at step 5. The copy stays trueOmar
M4
Identified bars
Every purchase attaches to exact piecesReservation decrements a per-asset firm-inventory balance (a quantity per asset, no serials)Quantity at reservation, serials at allocation. The customer sees serials on Completed and on the record, never before paying. “Every gram is real” is satisfied at CompletedOmar + Seif E.
M5
Price components
Payment fee and tax may need their own lines{MetalValue, Premium} onlyFix the four kinds in 4.4 now, zero-valued where unusedOmar · Ibrahim (D5) · Seif Gad (D6)
M6
Payment method
Card, InstaPay, bank transfer rails in the app; InstaPay is “primary” upstream (PAY5, GTM7)PaymentMethodId = a stored record; a first-time InstaPay payer has nothing stored (CODE108 open)Launch rails: card via Paymob hosted checkout. InstaPay only if the rail contract exists by launch. The spike says it does not yet. Request shape {rail, saved_id?} so a rail with nothing stored is expressibleIbrahim (rails)
Seif E. (shape)
M7
Checkout surface
Native card form inside the sheet (as built, never wired)PaymentSession.CheckoutUrl, a hosted Paymob pageHosted checkout in an in-app browser sheet. No card data touches ASL. On return the app polls the orderOmar
M8
Vault destination
Buy carries the destination vault as a label on the order and slipNothing on PlaceBuyOrderCommandAdd optional vault_id to the order. Label only; allocation logic untouchedSeif E.
M9
Copper
Launch requirement (PRODUCT §3)The asset catalog can carry itShip the gate and keep the metal off: enabled=false until supply + approval. The app already hides itIbrahim
M10
Expiry on the wire
Never displayed; used to time the re-quoteAlready sends ExpiresAt as an absolute valueNothing to change. App: local compare to decide when to re-quote. Server: sole enforcementSettled

§6Decisions register

IbrahimOmarSeif Gad

Every row is open. An open row blocks the code it governs, so the dates are set so the backend is not blocked this week. Recommendations are Omar's; the owner's answer replaces them.

IDQuestionOptionsRecommendationOwnerNeeded by
D1One line per order at launch, or mixed cart? (M1)a) one line
b) mixed all-or-nothing
aOmar + Ibrahim17 Sept
D2What is “metal value” on Price details: public spot or supplier trading price?a) named public spot, EGP/g
b) supplier price
a. Matches the Sabika / Nagmeddeen convention and the customer's expectation; supplier cost stays private; ASL's own margin math is internal. Name the reference source and side (bid/ask)Ibrahim + Seif E.17 Sept
D3ASL markup per metal at launchEGP per incrementBusiness-model floor is EGP 50 per increment. Confirm per metal; gold 1 g / silver 10 g / copper 100 g are not yet approved sizesIbrahim19 Sept
D4Quote lifetime (M2)30 s / 120 s / 5 min120 sIbrahim17 Sept
D5Payment fee: pass through, absorb, or fold into the premium?a / b / cb for InstaPay (cheap), a for card, shown at the method choice. Reconsider once Paymob terms are signedIbrahim19 Sept
D6VAT treatment per line (metal, premium, manufacturing, fee)Do not guess 14 %. Get the written answerSeif Gad26 Sept
D7Storage free at launch?yes / noYes; the business model puts 0.25 % post-launchIbrahim19 Sept
D8Launch payment rails (M6)card / card + InstaPay / + bank transferCard; add InstaPay the day the contract existsIbrahim19 Sept
D9Own inventory or consignment at launch (PAY7)? It sizes the internal price guardown / consignment / mixWhatever the Nagmeddeen terms say, as long as it is written down; it sizes M2 and the guardIbrahim19 Sept
D10Launch SKU ladder per metalsizesOne size per metal for v1 (simplest for M1 / M4). Sizes come from the supplier list; the old 1 / 10 / 100 assumption is unapprovedIbrahim + supply19 Sept
D11Copper at launch (M9)yes / gatedGatedIbrahim + Seif Gad26 Sept
D12Premium label wording EN / AR“Bar premium / علاوة السبيكة”; native Egyptian reviewOmar19 Sept

§7Not in Buy v1

Everyone
  • Mixed all-or-nothing cart
  • InstaPay and bank transfer until contracted
  • Cash at the counter
  • Instalments / BNPL
  • Any wallet or residual balance
  • Scheduled monthly purchase
  • Moving bars between vaults
  • Any timer or held-until time on the Buy or review screens
  • The opposite-side (sell) price on the buy review

§8Glossary

IbrahimSeif Gad
WordPlain meaning
EstimateToday's price for the cart, with no promise attached
QuoteA firm price for this exact cart, signed by the server, usable once, and checked again before any payment is accepted
OrderThe quote accepted; a payment attempt exists; bars are reserved; the amount is fixed
Payment attemptOne try at charging the fixed amount through one rail. It can succeed, fail, or be unknown for a while
AllocationAssigning specific bars (serials) to this order after the money is confirmed
Ownership recordThe server-issued document that proves which bars the customer owns
PremiumEverything in a bar's price above the metal value: the supplier's manufacturing and distribution, ASL's markup
SpreadThe gap between what a customer pays to buy a bar and what ASL pays to buy it back. It is the difference between two prices, with no third charge
ReservationHolding stock for an order so two customers cannot buy the same bar
IdempotencyIf the phone sends the same Pay twice because the network dropped, the customer is charged once and gets the same order back. Every money step needs it
Price guardAn internal check at Pay: if the market moved more than the premium can absorb since the quote, the order fails and the customer is refunded. The customer is never asked to accept a new number after tapping Pay