A reseller platform looks simple: someone buys at one price, sells to another and keeps the difference. Until there are three levels of users, balances moving between them, several providers and orders that sometimes end up half done. That is where systems break. This is what I learned building a multi-level panel like that, the one for Recargas América.
1. Each network sees only its own data
With an administrator, resellers and sub-resellers, the first risk is someone seeing another network’s data. What works: every sensitive table carries the identifier of its network (a tenant_id) and every query is filtered by it automatically, not by hand in each controller.
In Laravel this is solved with a global scope that adds the filter to queries and fills in the identifier on create. If you depend on every developer remembering to filter, sooner or later someone will forget.
2. A wallet is a ledger of movements, not a number
If you store only a balance and add or subtract from it, you will never be able to explain what happened. Do it this way instead:
- Every charge, credit, refund or transfer is a movement with a type, amount, status and context data.
- The balance is updated inside the same transaction that records the movement.
- Transfers between levels generate two movements linked by a group identifier: one outgoing and one incoming.
- Amounts are stored as decimals, never as floating-point numbers.
To avoid double charges when two purchases arrive at the same time, lock the wallet row while you modify it:
DB::transaction(function () use ($walletId, $amount) {
$wallet = Wallet::whereKey($walletId)->lockForUpdate()->firstOrFail();
if ($wallet->balance < $amount) {
throw new InsufficientFunds();
}
$wallet->decrement('balance', $amount);
$wallet->transactions()->create([
'type' => 'purchase',
'amount' => -$amount,
'status' => 'processing',
]);
});
3. Orders have states, and some get stuck
When you buy from an external provider over HTTP, three things can happen: it answers yes, it answers no, or it doesn’t answer. The third one is the dangerous one. That is why every movement has states (processing, completed, refunded) and a scheduled recovery task checks what stayed in processing:
- If the order did reach the provider, it queries its status and confirms it.
- If it failed, it refunds the reseller.
- If it is unknown, it retries up to a limit.
The hardest bug I found was a race condition: the recovery task refunded an order that was still in progress, and when the provider’s late response arrived with success, the product was delivered but the charge was already reversed. The fix was to have a single point apply the final result atomically and check the current state before changing anything.
4. Cascading prices, always with a floor
Each level sets its price on top of the previous level’s. The basic rule is that nobody sells below what they are charged:
// The effective price never drops below what the platform charges
$price = max($tenantPrice ?? $basePrice, $basePrice);
The platform doesn’t charge below the provider’s cost either. And since costs change, when syncing the catalog the sale price only goes up when the cost really goes up. If the cost drops or stays the same, it is left alone; that way a sync never ruins a margin.
Every cost or price change is saved in a write-only history, and only when something really changed, so the table doesn’t fill up with identical rows.
5. Several providers, one catalog
If two providers sell the same product under different codes, the reseller shouldn’t see two rows. It is better to group equivalent products under a common name, show only one and, when buying, pick the cheapest available offer and move to the next provider if the first one fails. Watch out for one detail: the price charged to the reseller is calculated on the group (the highest), not on the provider the purchase finally went through.
6. The minimum security
- Attempt limits on logins and verifications.
- Webhooks verified by signature before crediting a single cent, and with a request limit.
- Limited API keys per user, with a separate test environment and enabled only if the plan includes them.
- Plan-based control: each feature is enabled according to the reseller’s plan and the routes check it.
- Impersonation with a way back: if the administrator enters as a reseller to give support, there must be a single path to return to their own session.
- Credentials outside the files: payment and provider credentials are managed per account from the panel.
If you are going to build something similar
Almost all of this goes unseen and weighs far more than the interface: isolation, recorded movements, clear states and price floors. If you are going to build a platform like this, see how I work on Laravel development or tell me your case.