Case study · Marketplace & payments · 2026

One checkout that pays every supplier separately

Buyers of new apartments from a large Israeli residential developer can now furnish and upgrade their home from vetted suppliers in one Hebrew storefront. They enter a card once; the platform splits the payment between suppliers, keeps every catalog in sync each night, and gives the developer's staff one console to run it all.

.NET 8 / ASP.NET CoreEF Core · SQL ServerReact · TypeScript · VitePayment marketplace APIWindows Service workerS3 · SMTP · SMS · CRM
Client
Large Israeli residential real-estate developer (confidential)
My role
Team lead & architect (hands-on), across all three apps
Timeline
Jul – Aug 2026 · ≈5 weeks to feature-complete on QA
Status
Delivered · client launching
At a glance
1 to None card entry, then a separate capture for each supplier's share
≈5 wksto all planned modules end-to-end, against a 10-week plan
5,000 to 1alert lines for one broken feed column, now grouped by cause
0payment-callback fields trusted. Every sale is re-read from the provider.
404automated tests on the backend
90API endpoints across a public and an admin API
25coded feed checks across Excel, HTML and CSV feeds
3 appsbackend, storefront and console, in 30 calendar days of commits
The problem

Many suppliers, one buyer, one card

The developer wanted to offer its apartment buyers a curated catalog: kitchens, lighting, appliances and fit-out work, sold by independent suppliers at prices that differ before and after the buyer takes occupancy. That raises three problems at once. Each supplier publishes products in its own spreadsheet or web table, so the catalog has to be rebuilt from moving files every night without losing the developer's own edits. Buyers must sign in against the developer's CRM and see only what applies to their apartment. And a payment provider sale belongs to exactly one seller, so a cart with three suppliers cannot simply be one charge.

What I owned

From the plan to a working QA system

  • Wrote the backend development plan from the client's functional spec and set the layered architecture: two APIs over one shared core and a separate sync worker.
  • Built the large majority of the backend, the admin console and the buyer storefront myself, and led the team through tester and manager review rounds.
  • Designed the split-payment flow with the payment provider, then verified it end to end in their sandbox before writing it into the product.
  • Built the feed engine: three readers, 25 coded checks, override-aware upsert, logical delete and a Hebrew failure report.
  • Diagnosed the integration failures (mail relay, corrupt feeds, duplicate callbacks) and documented each one for operations.
Architecture

Two APIs, one shared core, a worker on the side

The diagram is wider than the screen: swipe sideways to see all of it →

The public and admin APIs are separate hosts, so the buyer-facing surface never exposes management endpoints. Both, and the worker, register the same core through one shared registration call, so business rules cannot drift between them. Each external service sits behind an interface, and an empty setting selects a fail-closed stub, which keeps developer machines away from real payments, SMS and mail.

Design decisions

The feed is the source, the cart is the truth

Authorise once, capture per supplier

The buyer's card is authorised once for the cart total against the marketplace's own seller. After approval the order is created, and each supplier's share is captured separately against that supplier's seller account. A failed capture leaves the order Pending for reconciliation rather than deleting an order the buyer has already paid for. Trade-off: every supplier needs full KYC with the provider, so a single flag falls back to charging the whole cart and settling suppliers outside the system.

Never trust the callback

The payment callback is anonymous, so anyone can post it. It is only a prompt: the server re-reads the sale from the provider, matches the reference it issued to the cart, checks the amount against the cart total, and returns the existing order on a repeat delivery. Checkout details are stored on the cart before the redirect, so the callback never has to carry them.

Overrides live beside the feed, not in it

Staff can override five fields of a supplier product (description, both prices, active flag, category). Overrides sit in their own table and are projected into precomputed Effective* columns on every sync and every edit. The storefront reads only those columns, an override takes effect immediately, and it survives the next night's import. Trade-off: every write path must call the projection, which the tests enforce.

Delete logically, snapshot every order

A product that disappears from a feed is hidden, not removed, and comes back with its overrides if it reappears. Orders store a full snapshot of items, prices, suppliers, variants and addresses, so order history stays correct whatever happens to the catalog later.

The worker is a Windows Service, not a web site

The nightly sync runs as its own process with its own settings, outside IIS, so an app pool that recycles or idles cannot skip a night. Imports of the same supplier are serialised and committed in one transaction, so a manual sync racing the nightly run cannot double-insert or leave half a catalog.

Engineering log

What broke, and how it was fixed

Most of the hard work was at the edges: the payment provider, the mail tenant, and supplier files nobody controls. A selection:

Jul 22

Content pages could carry script

Rich-text content entered in the console was rendered as raw HTML on the storefront, a stored-XSS path.

Sanitised CMS HTML before rendering, in the same week the storefront was first committed.

Jul 22

Soft delete broke uniqueness

Unique indexes on names and slugs counted soft-deleted rows, so a deleted category blocked a new one with the same name.

Moved to filtered unique indexes that ignore deleted rows, and fixed the confirmed admin-flow bugs found in an end-to-end review across 7 flows.

Aug 2–5

Mail refused, then misdiagnosed

The client's mail tenant began rejecting the deployed app as an unauthorised sender. The first diagnosis, a missing TLS client certificate, was wrong.

The real cause was the source IP: once the server's address was allow-listed, mail flowed with no certificate. I documented why the other settings must stay empty, added an SMTP probe that stops before sending, and an admin-only diagnostics endpoint that sends through the real path and reports which provider answered.

Aug 3–4

One sale, one seller

The payment provider ties each sale to exactly one seller, so a multi-supplier cart could not be one charge.

Agreed a two-step flow with the provider (authorise, then multi-capture) and ran it end to end in the sandbox: one authorisation, two captures to two suppliers. The run measured what the docs left vague: amounts in agorot, an authorisation that holds for 168 hours, a limit of 10 calls per 10 seconds, and an origin sale that stays “authorized” after partial capture.

Aug 4

The same payment, delivered twice

The provider retries a callback up to 3 times, so one payment could arrive more than once.

Added a unique index on the provider transaction and made the callback idempotent: a repeat returns the order that already exists.

Aug 6

A wall of identical errors

A feed missing one mandatory column raised one error per row. Thousands of identical lines hid every other fault in the run, and the supplier's file had often changed by the time anyone looked.

The Hebrew failure report now groups errors by cause, with a row count and example product IDs, and each run archives its own copy of the source file and links it from the report.

Aug 11–17

Sign-in by phone, without enumeration

The client moved buyer sign-in from national ID to phone number. A phone lookup before any code is sent can be used to discover who is a buyer.

Switched identity to phone across backend and storefront, and added a per-number attempt cap and lockout on the lookup step, separate from the cap on code guesses.

Aug 18

A corrupt file escaped the import

In QA, a damaged spreadsheet threw a framework exception whose name clashes with the engine's own feed exception. It escaped the handler, so no report was written, no alert went out, and a stack trace reached the API response.

Caught it explicitly. A corrupt file now produces a failed sync report and an alert, and the existing catalog stays live.

Walk through it

One card entry, three suppliers

An illustration of the checkout flow with made-up amounts and suppliers. It contains no client data or code. Step through it to see where each check happens.

Outcome

Where it landed

Area
Before
After
Paying suppliers
One sale per seller; suppliers settled by hand outside the system
One card entry, automatic capture per supplier, per-split status for reconciliation
Catalogue upkeep
Supplier spreadsheets re-keyed by staff (≈1–2 h per supplier per update, estimate)
Nightly import of Excel, HTML and CSV feeds with 25 coded checks, plus on-demand sync
Feed failure alert
One line per bad row, thousands for a single missing column
One grouped line per cause, with counts, examples and the archived source file
Local price edits
Lost on the next import
Five overridable fields, live immediately, kept across every sync
Payment callback
A public endpoint anyone can post to
Re-verified with the provider, matched to cart and amount, idempotent
Mail failures
Up to a day lost chasing the wrong cause (estimate)
One-click diagnostic in the console that names the failing layer
Delivery
10-week plan
All planned modules end-to-end on QA in ≈5 weeks; go-live waits on client-side items

Still open at hand-over, all on the client side: KYC for each supplier's seller account, the commission rate, and a public callback address registered with the provider.

Stack

Technology stack

Backend
.NET 8, ASP.NET Core, EF Core, AutoMapper, JWT, NLog
Data
SQL Server, 25 tables, 22 migrations, filtered unique indexes
Front ends
React 18, TypeScript, Vite, Redux Toolkit, Ant Design, i18next (680 Hebrew strings)
Feeds
ClosedXML, HTML table and CSV readers, Windows Service worker
Integrations
Payment marketplace API, CRM, two SMS gateways, SMTP relay, S3
Quality
404 xUnit tests, dev / QA / staging / prod branches, SIEM-format logging

Free scoping call · 30 minutes · No commitment

Bring one process. Leave with a plan and a number to beat.

Write a few lines: the process or system, what hurts today (time, errors, cost or an AI pilot that stalled) and who uses it. After a short call you get a written plan and a fixed quote within ≈3 days.

Get a free scoping callafif.mansour@gmail.com

Opens a pre-filled email · reply within one business day

The model reads. The code counts.

All projects