March 10, 2024 • 12 min read
Use Stripe. For most teams that's the whole answer, and I'd reach for it again without thinking. But some businesses hit its edges. At The Good Guys we ended up building our own payment orchestration, mostly because we had B2B transactions, installment plans, and a couple of legacy finance systems that weren't going anywhere and weren't going to bend to fit a standard checkout. This is when that's worth doing, and how not to set fire to your PCI scope while you do it.
Building your own buys you control. It also signs you up for compliance work that doesn't end. Someone owns PCI forever now, and that someone is you.
Get PCI scope right first. Every part of your system that touches a raw card number is a part the auditors get to inspect, so the trick is to make that surface as small as you can. Here's roughly how we laid it out:
// PCI-compliant payment architecture
Payment Gateway Integration:
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (PCI DSS Scope: Minimal) │
│ • Token-based payments only │
│ • No cardholder data storage │
│ • HTTPS everywhere │
│ • CSP headers and security policies │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Payment Orchestration Layer (PCI DSS Level 1) │
│ • Vault tokenization service │
│ • Multi-processor routing │
│ • Fraud detection engine │
│ • Secure key management (HSM) │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Payment Processors │
│ • Primary: Direct bank relationships │
│ • Secondary: Stripe/Adyen for fallback │
│ • Regional: Local processors for specific markets │
└─────────────────────────────────────────────────────────────────┘Marketplace payments are where it gets fiddly. One buyer pays once, but the money has to split across the sellers, your platform fee, and the processor. Get the order of operations wrong and you end up holding funds you have no right to hold, which is a regulatory problem, not a bug. Here's the shape of it:
// Custom marketplace payment flow
public class MarketplacePaymentService
{
public async Task<PaymentResult> ProcessMarketplacePayment(
MarketplacePaymentRequest request)
{
// Step 1: Validate all parties and amounts
var validation = await ValidateMarketplaceTransaction(request);
if (!validation.IsValid)
return PaymentResult.Failed(validation.Errors);
// Step 2: Create escrow hold
var escrowResult = await CreateEscrowHold(request.TotalAmount);
if (!escrowResult.Success)
return PaymentResult.Failed("Escrow creation failed");
// Step 3: Process payment to escrow
var paymentResult = await ProcessPaymentToEscrow(
request.BuyerPaymentMethod,
request.TotalAmount,
escrowResult.EscrowId
);
if (!paymentResult.Success)
{
await ReleaseEscrowHold(escrowResult.EscrowId);
return PaymentResult.Failed("Payment processing failed");
}
// Step 4: Calculate and distribute funds
var distributions = CalculateDistributions(request);
var distributionResults = new List<DistributionResult>();
foreach (var distribution in distributions)
{
var result = await TransferFromEscrow(
escrowResult.EscrowId,
distribution.RecipientId,
distribution.Amount,
distribution.Description
);
distributionResults.Add(result);
}
// Step 5: Handle any failed distributions
var failedDistributions = distributionResults
.Where(r => !r.Success).ToList();
if (failedDistributions.Any())
{
await HandleFailedDistributions(
escrowResult.EscrowId,
failedDistributions
);
}
return PaymentResult.Success(paymentResult.TransactionId);
}
private List<Distribution> CalculateDistributions(
MarketplacePaymentRequest request)
{
var distributions = new List<Distribution>();
var remainingAmount = request.TotalAmount;
// Platform fee (our commission)
var platformFee = request.TotalAmount * 0.03m; // 3%
distributions.Add(new Distribution
{
RecipientId = "platform_account",
Amount = platformFee,
Description = "Platform commission"
});
remainingAmount -= platformFee;
// Payment processing fee
var processingFee = request.TotalAmount * 0.029m + 0.30m; // 2.9% + 30¢
distributions.Add(new Distribution
{
RecipientId = "payment_processor_account",
Amount = processingFee,
Description = "Payment processing fee"
});
remainingAmount -= processingFee;
// Distribute to sellers proportionally
foreach (var seller in request.Sellers)
{
var sellerAmount = (seller.ItemValue / request.ItemsTotal) * remainingAmount;
distributions.Add(new Distribution
{
RecipientId = seller.SellerId,
Amount = sellerAmount,
Description = $"Payment for items sold"
});
}
return distributions;
}
}# Custom Payment System ROI Analysis Development Investment: - Initial development: 6-12 months, $200K-500K - PCI compliance: $50K-100K annually - Ongoing maintenance: 2-3 FTE developers - Security audits: $25K-50K annually Break-even Analysis (Monthly): ┌─────────────────────┬─────────────┬─────────────┬─────────────┐ │ Transaction Volume │ Stripe Cost │ Custom Cost │ Savings │ ├─────────────────────┼─────────────┼─────────────┼─────────────┤ │ $100K │ $2,900 │ $25K │ -$22,100 │ │ $500K │ $14,500 │ $30K │ -$15,500 │ │ $1M │ $29,000 │ $35K │ -$6,000 │ │ $5M │ $145,000 │ $50K │ +$95,000 │ │ $10M │ $290,000 │ $65K │ +$225,000 │ └─────────────────────┴─────────────┴─────────────┴─────────────┘ Sweet Spot: $3M+ monthly transaction volume Additional Benefits: • Complete control over user experience • Custom fraud detection and risk management • Direct processor relationships (better rates) • Advanced analytics and reporting capabilities
So none of this is a pitch to ditch Stripe. For most teams it's a bad trade. Work out what you actually need, add up the cost of owning it for years and not just building it once, and check you've got the security and compliance people to do it properly. If the numbers still say build, build. They usually don't.