Payments without international cards, phone numbers on three carriers, Arabic first interfaces, store reviews, and why we left Firebase.
Every tutorial starts the same way: add Stripe, send a magic link to an email address, lay the screen out from the left. Our users pay with an Edahabia card at the post office, sign up with a +213 number on one of three carriers, and read from the right. None of the three defaults apply, and each one bends the architecture more than it looks. This is what we learned across a dozen products, written down the way we wish someone had written it for us.
- 3
- carriers to test OTP delivery on, in week one
- 5 min
- OTP lifetime, checked server-side, five attempts
- 468
tests in one product, many of them RTL and contrast checks
- ~78
- files touched to leave Firebase, out of a whole app
Money first, then everything else
There is no Stripe here. Cards are Edahabia and CIB, transfers go through BaridiMob and CCP, and global processors cannot touch these rails. Our student-jobs marketplace runs its checkout through Chargily Pay from a serverless function, with a webhook that writes the subscription row, and its roadmap still carries an open line we have not fully solved: subscriptions on non-recurring payment rails. The working answer is renew-per-period: a plan is a dated purchase, a reminder nudges you to repeat it, and a lapse is a state the app degrades into, never a failed charge that locks the door.
For a restaurant ordering app we sidestepped payment entirely. Food is physical goods, so Apple's in-app purchase is forbidden, and the app collects nothing: pay at the counter or on delivery. The App Store reviewer notes say so in plain words, and the readiness audit checks that no purchase API exists anywhere in the codebase.
The constraint reaches further than checkout. A cloud-only backend forces an international card on every customer just to pay for hosting. That is why Batipro's sync layer is self-hostable, and why some customers run it on a VPS in Algeria billed in dinars with a local invoice.
Three carriers, one SIM each
Every consumer product here signs up with a phone number. The single most common blocker we have seen is SMS delivery, so our notes for the equipment marketplace say it bluntly: verify delivery to Djezzy, Ooredoo and Mobilis with real numbers on all three carriers during week one, and plan a WhatsApp OTP fallback if it is unreliable.
How we do the OTP itself: the six-digit code is generated, hashed and checked entirely server-side, in a table the client can never read, with a five-minute lifetime and a maximum of five attempts. The app only ever sees { ok }. The SMS provider is deliberately unbound, any Algerian aggregator or Twilio, and with the send URL left empty the function logs the code to its execution log so development does not burn credit.
Sometimes the right answer is not SMS at all. SMS costs money, so the kindergarten product uses director-issued invite codes plus a device-bound session, and self-registration is deliberately unsupported. One rule from that codebase we now apply everywhere: auth is the one thing that cannot be queued. A launch with no network waits instead of signing anyone out; only a real 401 does.
Right to left from the first commit
Retrofitting right-to-left is painful, so we stopped retrofitting. The rules that are now enforced by analyzer config and tests in our newer projects:
- Use
EdgeInsetsDirectional,AlignmentDirectionalandstart/end. Neverleft/right. - Test every screen in both locales from the first week. The equipment marketplace ships with 468 tests across about thirty files, and a good share of them are i18n, RTL and contrast checks.
- Bundle the fonts. Tajawal for Arabic, shipped in the app, because the app is used where there is no Wi-Fi and a font that loads at runtime is a font that sometimes does not.
- Keep the mark as vector geometry, never as a font glyph, so it renders the same in every locale.
- Write the Arabic, do not translate it. Two commits on the Batipro site say it best: "plain Arabic, not literary Arabic" and "the Arabic was translated, not written".
“Plain Arabic, not literary Arabic. The Arabic was translated, not written.
”
The commit log of the towing app shows what the retrofit looks like when you do have to do it: directional insets, mirrored arrows, a locale-safe model helper, and a separate change to pin a navigation chevron to the row end. Three weeks that would have been three days at the start.
What the reviewer sees
The App Store readiness audit we wrote for the restaurant app is organised by the rejection reasons developers most often hit. The ones that changed our defaults:
- No tracking prompt if you do not track. Apple rejects apps that show the prompt without using the identifier.
- A real 1024 by 1024 icon. Auto-resizing a wordmark into a square is a common rejection, and it looks bad anyway.
- Lock the iPhone to portrait unless every screen truly handles landscape. Declaring support you do not have is a rejection.
- Bump the build number on every upload. Re-uploading the same number is refused.
- A hard offline gate that replaces the whole screen is too aggressive for reviewers, who test on weak networks and like to see cached content still render. Show a banner instead.
- Pre-write the reviewer notes. Ours explain why "submit order" works without a payment step, and that test orders will show up in our dashboard under the reviewer's name and be discarded.
On the Play side, the boring parts are the ones worth automating: the data-safety declaration is generated from a script, the console steps that have no API are documented step by step, and the release notes are written in English, French and Arabic in the changelog itself so the store listing never lags.
One embarrassing find from our own code review: the towing app had over-the-air patching configured correctly, and the release lane bypassed it by running a plain build. Every Play release for months could not receive a patch. The configuration was right; the pipeline was not.
The migration that was not a config swap
Our auto-parts marketplace started on Firebase and moved to Appwrite. The reasons were the usual ones, self-hosting, data residency, predictable cost instead of per-read billing, with the honest caveat in our own document that the cost case has to be weighed against the cost of migrating and operating.
The verdict we wrote up front: this is a major re-platform. Counting usages in the codebase made it concrete. About 55 transactions, 60 batched writes, 59 collection-group queries and 209 field-value operations had no one-to-one equivalent, and schemaless documents became predefined attributes. Realtime subscriptions, phone OTP and storage mapped cleanly.
Two things made it survivable. Clean architecture confined the work to the data layer, about 78 Dart files, and the 18 cloud functions, instead of a rewrite. And about 398 unit tests plus integration tests against a real Appwrite instance ran before anyone trusted the cutover. They still surfaced real bugs: queries using old Firebase field names that did not exist any more, a schema audit that found ten attributes missing from the live project, and a handful of stuck spinners.
Two features quietly disappeared in the move and had to be rebuilt six weeks later, which is its own lesson.
If you ship here
- Pick the payment rail first, then the backend.
- Buy a SIM on each carrier. Test OTP delivery before you write the onboarding.
- Ask whether you need SMS at all.
- Go RTL on day one, bundle the fonts, write the Arabic.
- Write the reviewer notes before the reviewer asks.
- Count the API surface before you migrate, and keep the old feature list until the old backend is off.