Daigou Code: How We Solved Payment Reconciliation Nightmares in Cross-border Order Management

php dev.to

Last month, a client pulled me into their finance Slack channel. Their operations lead had spent four nights reconciling orders from the previous quarter. The ledger showed total revenue around $47,000, but the payment gateway settlement came in at just over $45,200. Almost two grand missing.

The usual suspects were innocent: gateway fees matched the contract, refunds were accounted for, exchange rates didn't explain that gap. Three days later we found it — a cron job that retried failed payment callbacks, and the system processed the same webhook twice for about 120 orders over two months. Each double‑processed callback didn't charge the customer again, but it did insert duplicate reconciliation records that inflated the ledger.

That's when I stopped blaming Excel and started fixing the code.

Solution

The root cause was simple: no idempotency layer in payment callbacks. Every external webhook — from PayPal, Stripe, or any local gateway — was handled by a controller that verified signature, updated order status, and logged the transaction. If the same payload arrived twice (network retry, gateway resend), the system inserted duplicate records.

We redesigned the callback handler around a request idempotency key. The pattern is now standard in our daigou code base.

// Callback controller snippet
public function handlePaymentCallback(Request $request)
{

$payload = $request->getContent();

$idempotencyKey = $request->header('X-Idempotency-Key') ?? md5($payload);

// Try to acquire lock with Redis

$lockKey = "payment:lock:{$idempotencyKey}";

$lock = Redis::setnx($lockKey, time() + 10);

if (!$lock) {

// Another process is handling the same callback

return response('Processing', 202);

}

DB::beginTransaction();

try {

// Check if already processed

$processed = DB::table('payment_idempotency')

->where('key', $idempotencyKey)

->exists();

if ($processed) {

DB::commit();

return response('OK', 200);

}

// Extract order_id from payload

$orderId = $payload['order_id'] ?? null;

if (!$orderId) {

throw new \Exception('Missing order reference');

}

// Update order status with state machine check

$order = Order::find($orderId);

if ($order->status === 'paid') {

// Already paid, still record idempotency but skip double action

$this->recordIdempotency($idempotencyKey, $orderId);

DB::commit();

return response('OK', 200);

}

$order->status = 'paid';

$order->paid_at = now();

$order->save();

// Record idempotency entry

$this->recordIdempotency($idempotencyKey, $orderId);

DB::commit();

Redis::del($lockKey);

return response('OK', 200);

} catch (\Exception $e) {

DB::rollBack();

Redis::del($lockKey);

Log::error('Payment callback failed', ['key' => $idempotencyKey, 'error' => $e->getMessage()]);

return response('Failed', 500);

}
}

private function recordIdempotency($key, $orderId)
{

DB::table('payment_idempotency')->insert([

'key' => $key,

'order_id' => $orderId,

'created_at' => now(),

]);
}
Enter fullscreen mode Exit fullscreen mode

The payment_idempotency table has a unique index on key, so even if Redis lock fails (rare), the database insertion prevents the second write.

We also locked the exchange rate at order creation time. Before, the system used a live rate at payment confirmation — which could be hours or days after the customer clicked "buy". Now the orders table stores exchange_rate_used and base_currency_amount. The customer sees the final price immediately, and the finance team reconciles against that frozen rate.

ALTER TABLE orders ADD COLUMN exchange_rate_used DECIMAL(10,6) NOT NULL DEFAULT 1.0;
ALTER TABLE orders ADD COLUMN base_currency_amount DECIMAL(12,2) NOT NULL;
Enter fullscreen mode Exit fullscreen mode

Order creation flow:

$rate = Redis::get('cny_to_usd_rate') ?? 6.45; // cached, refreshed every 5 min
$order->exchange_rate_used = $rate;
$order->base_currency_amount = $cartTotalUSD * $rate;
$order->save();
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

Idempotency is not just about deduplication — it's about defining what "once" means. Our first implementation used the raw JSON payload as the idempotency key. That broke when gateways added a timestamp field that changed between retries. We switched to a composite key: gateway name + gateway transaction ID. That survived retries.

The lock + database index combination saved us from deadlocks. Early on we tried using only a UNIQUE index on the idempotency table. Under high concurrency (about 30 callbacks per second during flash sales), the database threw duplicate key exceptions, and our error handler retried the whole request, causing a cascade. Adding the Redis lock reduced duplicate insert attempts by about 95%.

Exchange rate caching has a hidden trade‑off. We refresh the Redis cache every 15 minutes. During extreme volatility (like JPY moving 1.5% in an hour), the customer pays a rate that's already stale. We added a safety valve: if the external rate deviates from cached rate by more than 2%, the system rejects the order and asks the customer to retry. This happens on maybe 1% of orders, but it saved us from losing money on another 3-4% of transactions.

Reconciliation still needs a human in the loop. We built a daily diff report that compares gateway settlement files (CSV) with our payment_idempotency and orders tables. Any mismatch over 0.5% triggers an alert. The report cut monthly reconciliation time from about 15 hours to under 2 hours.

One thing we didn't anticipate: partial refunds. When a customer returns two of five items, which exchange rate do you use for the refund? The original locked rate or the current rate? We settled on original rate to avoid customer complaints, but that means you eat the currency risk on refunds. Our solution now is to hold a small buffer (around 0.6% of order value) in a separate account specifically for refund exchange losses.

Conclusion

Payment reconciliation in cross-border daigou isn't a math problem — it's a design problem. Idempotency keys, rate locking, and daily diffs turn a "where did the money go" mystery into a routine check. Start with the database unique index, add Redis locks when concurrency grows, and never assume a webhook arrives exactly once.

If your team still reconciles with Excel and coffee at midnight, you're one flash sale away from a very bad week. The code above is production‑tested on a system handling a few hundred orders daily. Full example with migration files and test webhook simulator is in our internal repo — adapt the pattern to your stack.


About the Author: I'm a CTO who's built and scaled cross‑border purchasing platforms for the past decade. Currently working with Taocarts — a daigou system that handles order management, warehouse coordination, and international shipping for 1688/Taobao sourcing. The idempotency pattern described here runs in production on Taocarts deployments.

Source: dev.to

arrow_back Back to Tutorials