Loading...
Loading...
Most chargebacks aren't fraud — they're refunds that were missed or delayed. FinOps Ops Console treats refund risk as a continuous, time-based state rather than a binary status.
Field Note — FinOps Ops Console
---
Chargebacks are the tax on poor refund operations. In most payment stacks, refunds arrive as scattered emails or isolated events with no clear deadline visibility. By the time a chargeback appears, the window to prevent it has usually already closed.
FinOps Ops Console was built on a simple insight: most chargebacks are missed deadlines, not fraud. A customer requests a refund. The request sits in a support queue. The SLA passes. The customer disputes the charge with their bank. The merchant pays the chargeback fee, loses the transaction fee, and damages their processor relationship.
The console treats every refund as a time-sensitive workflow with automatic risk classification, alerting, and universal search — and seeds realistic demo data straight from the Stripe API so the risk logic can be seen working on genuine data shapes.
---
In a typical payment stack, refunds are handled by support teams using ticketing systems (Zendesk, Intercom) or spreadsheets. The payment processor (Stripe, Paystack) knows about the refund. The support team knows about the refund request. But neither system knows what the other knows.
The result:
FinOps Ops Console unifies this into a single operational view.
---
The console is multi-tenant by workspace. Each merchant or team gets their own workspace with isolated data, separate provider credentials, and independent alert configurations. Authentication is session-based (not JWT) because the console is a traditional web application, not an API-first service. Session cookies are HttpOnly, Secure, and SameSite=Strict.
Workspace scoping is enforced at the queryset level:
class RefundQuerySet(models.QuerySet):
def for_workspace(self, workspace):
return self.filter(workspace=workspace)Every view starts with workspace = get_current_workspace(request) and every query uses .for_workspace(workspace). This is defense in depth: even if a view forgets to scope, the queryset pattern makes it obvious in code review.
The core of the console is the risk state machine. Every refund is classified into one of four states based on time elapsed since the refund request:
class RefundRiskState(models.TextChoices):
SAFE = "SAFE", "Safe — within SLA, no action needed"
DUE_SOON = "DUE_SOON", "Due Soon — approaching SLA deadline"
AT_RISK = "AT_RISK", "At Risk — SLA missed, chargeback likely"
OVERDUE = "OVERDUE", "Overdue — chargeback probable or already filed"The thresholds are configurable per workspace and per provider:
SLA_CONFIG = {
"stripe": {"safe_hours": 0, "due_soon_hours": 48, "at_risk_hours": 72, "overdue_hours": 168},
"paystack": {"safe_hours": 0, "due_soon_hours": 24, "at_risk_hours": 48, "overdue_hours": 96},
"shopify": {"safe_hours": 0, "due_soon_hours": 36, "at_risk_hours": 60, "overdue_hours": 120},
}This time-based classification turns a static "refund status" into a dynamic risk signal. A refund that was SAFE yesterday might be DUE_SOON today, AT_RISK tomorrow, and OVERDUE next week — without any human intervention. The system recomputes states on every page load and via a background job every 15 minutes.
The dashboard UI is designed around alerts, not lists. When a user logs in, they see:
1. Alert summary: "3 refunds AT_RISK, 1 refund OVERDUE" with direct links to those items.
2. Risk timeline: A chart showing refund volume and risk state distribution over the last 30 days.
3. Universal search: Search across customer name, transaction ID, refund ID, order number, or support ticket reference.
The navigation is alert-driven because the user's job is to act on risk, not to browse refunds. A support manager doesn't need to see all 500 refunds; they need to see the 4 that are about to become chargebacks.
The console connects to Stripe, Shopify, and Paystack using API keys. These keys are encrypted at rest using AES-256-GCM with a key derived from the workspace's master key and a per-credential salt:
class ProviderCredential(models.Model):
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE)
provider = models.CharField(choices=Provider.choices)
encrypted_key = models.BinaryField()
salt = models.BinaryField()
key_prefix = models.CharField(max_length=12) # e.g., "sk_live_..." prefix for identificationThe master key is stored in an environment variable, never in the database. A database breach exposes encrypted blobs and salts, but without the master key, the credentials are useless. The key prefix lets users identify which credential is which without decrypting.
Demo data is critical for an ops tool. A dashboard with no data is unconvincing. But fake data — "John Doe, $100, Refund #12345" — doesn't exercise the risk engine realistically.
We built a demo seeding pipeline that:
1. Generates real test payments via the Stripe API in test mode.
2. Creates refunds for a subset of those payments.
3. Time-shifts the refund requests so they naturally populate every risk state: some are recent (SAFE), some are 2 days old (DUE_SOON), some are 4 days old (AT_RISK), some are 10 days old (OVERDUE).
The result is a dashboard that looks and behaves exactly like a live workspace, with genuine Stripe data shapes and realistic risk distributions. When a prospect sees the console, they see their own problem — refunds about to become chargebacks — not a toy example.
---
The initial design used a binary status: "refunded" or "not refunded." This was wrong. A refund request that hasn't been processed for 48 hours is not the same as one that hasn't been processed for 5 minutes. The risk accumulates over time.
Moving to a four-state model (SAFE, DUE_SOON, AT_RISK, OVERDUE) was the right decision, but it required rethinking the entire UI. Lists became timelines. Status filters became risk filters. The primary action on each refund became "process now" or "escalate" rather than "view details."
Static JSON fixtures would have been easier. But they wouldn't have tested the Stripe integration, the time-shifting logic, or the risk state transitions. The demo seeding pipeline is slower (network calls to Stripe's test API) and more complex (handling Stripe rate limits, cleaning up test data on teardown), but it produces a demo that genuinely proves the product works.
Each workspace might connect to Stripe for payments, Shopify for orders, and Paystack for local transactions. Each provider has different API formats, different rate limits, and different error behaviors. We abstracted provider interactions behind a ProviderClient interface:
class ProviderClient(ABC):
@abstractmethod
def fetch_refunds(self, since: datetime) -> list[Refund]:
pass
@abstractmethod
def process_refund(self, refund_id: str) -> RefundResult:
passStripe, Paystack, and Shopify each implement this interface. The console doesn't know which provider it's talking to; it just calls client.fetch_refunds() and gets back a normalized Refund object. This abstraction let us add Shopify support in two days, not two weeks.
---
Most chargebacks are missed deadlines, not fraud — solving for visibility prevents more disputes than solving for detection after the fact. A chargeback prevention tool that detects fraud is useful. A refund management tool that prevents the chargeback from ever happening is more useful.
An ops tool earns trust faster when it's convincing in demo mode before a single real provider is connected. The Stripe demo seeding was the highest-ROI feature we built. Prospects see their exact problem — refunds approaching SLA deadlines — in the first 30 seconds of the demo. No setup, no integration, no data import.
Modeled refund risk as four fixed states instead of a free-form status field — predictable UI and alerting logic, at the cost of less flexibility. The four-state model is opinionated. Some edge cases don't fit cleanly (a refund that's AT_RISK for one provider but SAFE for another). But the predictability is worth it. Users understand the four states. They don't understand a free-form status field with 47 possible values.
---
FinOps Ops Console is open source at [github.com/Gwerdonatus/FinOps](https://github.com/Gwerdonatus/FinOps)