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 are | Read | Skip |
|---|---|---|
| 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).
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:
| Moment | What it is | Money | Metal |
|---|---|---|---|
| Estimate | Today's price for what's in the cart | Nothing charged | Nothing reserved |
| Quote | A firm price for this exact cart. Checked again before any payment is accepted | Nothing charged | Nothing reserved (see M3) |
| Order | The quote accepted and a payment attempt opened | Payment in progress at the fixed amount | Reserved for this order |
| Completed | Payment confirmed and bars allocated | Paid | Owned, 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.
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.
| Step | What the customer sees | Made of | ASL's revenue | Who decides the number |
|---|---|---|---|---|
| Choose bars / Review | Bar price, one inclusive number per bar | Metal value + premium. Premium = supplier's premium (manufacturing, etc.) + ASL markup | The ASL markup. Business model: never below EGP 50 per increment | Ibrahim (amount, per metal) · Ibrahim + Seif E. (basis, D2) |
| Cart / Review | Payment fee, if any, shown at the method choice | Paymob card cost, InstaPay cost | None, unless it is rounded up | Ibrahim: pass through, absorb, or fold into the premium (D5) |
| Review | VAT, if any, as an included or added line | Tax on premium / manufacturing? On the metal? | None | Seif Gad (D6) |
| Storage | Nothing at launch | None at launch. Business model lists 0.25 %/yr as a future stream | Ibrahim, to reconfirm “free storage” is the launch promise (D7) | |
| Vault (label) | Nothing | None. No fee to create, move or dissolve a vault | Settled (PRODUCT §11) | |
| Later: Sell | EGP 50 per increment deducted | Sell-side markup | Next page | |
| Later: Collection | “Not yet quoted” until ops has a fee and a window | Possibly a collection fee | Next 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.
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.
| # | Screen | Customer sees | Customer can | Backend gives / does | What can go wrong here |
|---|---|---|---|---|---|
| 1 | Home | Holdings per metal, estimated EGP value, Buy | Tap 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 |
| 1a | Gate | KYC status / “You can't buy yet” | Complete KYC | can_transact on the profile | Not verified → no Buy. Never a fake success |
| 2 | Choose bars | Metal tabs (gold, silver, copper if enabled). For each: the sizes on the ladder, purity, inclusive unit price, availability, an integer stepper | Pick metal, size, quantity | GET /catalog: SKU ladder with purity, weight, unit_price (inclusive), components[], available, observed_at, status | Availability unknown (provider down) → “We couldn't check availability”, never “Sold out” |
| 3 | Cart | Lines (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 price | Edit qty, remove line, change method, change destination | Nothing new. Computed from §2 prices. v1: one line per order, see M1 | Empty cart → button disabled |
| 4 | Review purchase | Bars, payment method, destination, Total EGP X, Price details (metal value / premium / payment fee / VAT), Pay EGP X. No timer, no held-until time | Open Price details, open Help, switch language (keeps the quote), tap Pay | POST /quotes {sku_id, quantity, payment_rail} → signed quote token + readable fields: quote_id, unit_price, total, components[], observed_at, expires_at | Provider stale → “Price unavailable right now”, cart kept. Nothing on this screen counts down. |
| 4x | The price has changed | Same screen, bars still selected, and a calm line: “The price has changed. Review your updated total.” Old total → new total, plainly, in either direction | Approve the new total (a fresh Pay tap), or leave | Reached 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 4 | Never 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 |
| 5 | Pay | The 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 screen | Complete payment, or cancel | POST /orders {quote_token, payment_method, vault_id?} with Idempotency-Key → order_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 wallet | Quote 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 |
| 6 | Checking your payment | “Checking your payment”, the fixed amount, order reference, View order; Home shows a pending row | Leave and come back; nothing else | GET /orders/{id} polled: awaiting_payment → paid → allocated → completed, or failed | An uncertain outcome must never say “Nothing was charged”. Never reprice. Never retry the charge from the app |
| 7 | Completed | Bars with serials, total paid, date, destination vault, ownership record ID, share/print | View record, go to vault | GET /orders/{id} now carries pieces[] {serial, weight, purity} and record_id; GET /holdings updated | |
| 7x | Failed | Plain reason (declined, timed out, price guard tripped), “Nothing is owned”, refund status if money moved, Try again → back to 3 | Retry from the cart | Order failed with reason and refund_status | The 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 / لم يُخصم أي مبلغ.
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.
GET /catalog| Field | Meaning | Backend today |
|---|---|---|
| sku_id | One bar product: metal + size | none. Quotes are Asset + Milligrams; see M1 / M4 |
| metal | gold / silver / copper | AssetCode |
| weight_mg, purity | 1 000 mg, 999.9 | fixed lot per asset in Asset |
| unit_price | Inclusive price for one bar | UnitPrice |
| components[] | {kind, amount}, see 4.4 | PriceComponent {Kind, Amount} |
| available | true / false / unknown | none; a firm-inventory balance exists in Ledger |
| observed_at, status | when the base price was read; fresh / stale | ObservedAt; Price.IsFreshAt |
| enabled | metal on sale at all (the copper gate) |
POST /quotesRequest: {sku_id, quantity, payment_rail}. Response: the signed token plus the same fields decoded, so the app renders without parsing a JWT.
| Field | Meaning | Backend today |
|---|---|---|
| quote_token | Opaque, signed, single-use, bound to this customer | QuoteToken |
| quote_id | Shown on the order later | QuoteId (QuoteReference) |
| quantity, unit_price, total | For the review screen | Quantity, UnitPrice, Total |
| components[] | Price details | Components |
| observed_at | “Price as of” | ObservedAt |
| expires_at | Absolute UTC instant. Never shown to the customer. The app uses it to decide when to re-quote on return; the server enforces it at Pay | ExpiresAt. AgDR-C-0119 already made it an absolute value, which is correct |
| payment_fee | Fee for the chosen rail, if charged separately | none, see M5 |
POST /orders, GET /orders/{id}Request: {quote_token, payment_method: {rail, saved_id?}, vault_id?} + header Idempotency-Key.
| Field | Meaning | Backend today |
|---|---|---|
| order_id | Reference on every later screen and on the slip | OrderId |
| status | awaiting_payment · paid · allocated · completed · failed · cancelled | OrderStatus. Drop Initiated from the wire; the app never sees it |
| total | Fixed at placement; never changes after | QuotedTerms.Total |
| payment_session | {checkout_url, attempt_ref} for card; for InstaPay: ASL account, reference, amount | PaymentSession(CheckoutUrl, AttemptReference) |
| vault_id | Destination label. Settlement intent only; no money effect | none, see M8 |
| pieces[] | {serial, weight_mg, purity} once allocated | Ledger allocation; not yet |
| record_id | Ownership record, once completed | not yet |
| failure {reason, refund_status} | For screen 7x | not yet |
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.
| Kind | Label on Price details (EN / AR) | Notes |
|---|---|---|
| MetalValue | Gold price / سعر الذهب (per metal) | Reference × weight. D2 decides which reference |
| Premium | Bar 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 |
| PaymentFee | Payment fee / رسوم الدفع | Only if D5 = pass through. Shown at the method choice too |
| Tax | VAT / ضريبة القيمة المضافة | Only if D6 says so. Shown as included or added per the invoice rule |
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 says | The backend has | Recommendation for v1 | Decides |
|---|---|---|---|---|
| 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 quote | One 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 separately | Omar + 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 approval | Configured 5–30 s bounds (PricingOptions.QuoteOptions); the research in HANDOFF.md §34 found 5–10 min elsewhere | 120 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 number | Ibrahim (exposure) Seif E. (bounds) |
| M3 When bars are reserved | At Review final price: the quote reserves every piece | At Pay: PlaceBuyOrder reserves inside the order transaction; the quote is price-only | Accept the backend. Quote = price only; reservation at Pay. Add the “no longer available, nothing charged” state at step 5. The copy stays true | Omar |
| M4 Identified bars | Every purchase attaches to exact pieces | Reservation 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 Completed | Omar + Seif E. |
| M5 Price components | Payment fee and tax may need their own lines | {MetalValue, Premium} only | Fix the four kinds in 4.4 now, zero-valued where unused | Omar · 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 expressible | Ibrahim (rails) Seif E. (shape) |
| M7 Checkout surface | Native card form inside the sheet (as built, never wired) | PaymentSession.CheckoutUrl, a hosted Paymob page | Hosted checkout in an in-app browser sheet. No card data touches ASL. On return the app polls the order | Omar |
| M8 Vault destination | Buy carries the destination vault as a label on the order and slip | Nothing on PlaceBuyOrderCommand | Add optional vault_id to the order. Label only; allocation logic untouched | Seif E. |
| M9 Copper | Launch requirement (PRODUCT §3) | The asset catalog can carry it | Ship the gate and keep the metal off: enabled=false until supply + approval. The app already hides it | Ibrahim |
| M10 Expiry on the wire | Never displayed; used to time the re-quote | Already sends ExpiresAt as an absolute value | Nothing to change. App: local compare to decide when to re-quote. Server: sole enforcement | Settled |
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.
| ID | Question | Options | Recommendation | Owner | Needed by |
|---|---|---|---|---|---|
| D1 | One line per order at launch, or mixed cart? (M1) | a) one line b) mixed all-or-nothing | a | Omar + Ibrahim | 17 Sept |
| D2 | What 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 |
| D3 | ASL markup per metal at launch | EGP per increment | Business-model floor is EGP 50 per increment. Confirm per metal; gold 1 g / silver 10 g / copper 100 g are not yet approved sizes | Ibrahim | 19 Sept |
| D4 | Quote lifetime (M2) | 30 s / 120 s / 5 min | 120 s | Ibrahim | 17 Sept |
| D5 | Payment fee: pass through, absorb, or fold into the premium? | a / b / c | b for InstaPay (cheap), a for card, shown at the method choice. Reconsider once Paymob terms are signed | Ibrahim | 19 Sept |
| D6 | VAT treatment per line (metal, premium, manufacturing, fee) | Do not guess 14 %. Get the written answer | Seif Gad | 26 Sept | |
| D7 | Storage free at launch? | yes / no | Yes; the business model puts 0.25 % post-launch | Ibrahim | 19 Sept |
| D8 | Launch payment rails (M6) | card / card + InstaPay / + bank transfer | Card; add InstaPay the day the contract exists | Ibrahim | 19 Sept |
| D9 | Own inventory or consignment at launch (PAY7)? It sizes the internal price guard | own / consignment / mix | Whatever the Nagmeddeen terms say, as long as it is written down; it sizes M2 and the guard | Ibrahim | 19 Sept |
| D10 | Launch SKU ladder per metal | sizes | One size per metal for v1 (simplest for M1 / M4). Sizes come from the supplier list; the old 1 / 10 / 100 assumption is unapproved | Ibrahim + supply | 19 Sept |
| D11 | Copper at launch (M9) | yes / gated | Gated | Ibrahim + Seif Gad | 26 Sept |
| D12 | Premium label wording EN / AR | “Bar premium / علاوة السبيكة”; native Egyptian review | Omar | 19 Sept |
| Word | Plain meaning |
|---|---|
| Estimate | Today's price for the cart, with no promise attached |
| Quote | A firm price for this exact cart, signed by the server, usable once, and checked again before any payment is accepted |
| Order | The quote accepted; a payment attempt exists; bars are reserved; the amount is fixed |
| Payment attempt | One try at charging the fixed amount through one rail. It can succeed, fail, or be unknown for a while |
| Allocation | Assigning specific bars (serials) to this order after the money is confirmed |
| Ownership record | The server-issued document that proves which bars the customer owns |
| Premium | Everything in a bar's price above the metal value: the supplier's manufacturing and distribution, ASL's markup |
| Spread | The 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 |
| Reservation | Holding stock for an order so two customers cannot buy the same bar |
| Idempotency | If 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 guard | An 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 |