Overview
I designed and implemented an outgoing payments system for internal finance operations.
The core challenge was not just storing payments or rendering them in an admin table. It was building a trustworthy administrative surface across multiple sources of financial data without pretending they all belonged to the same workflow.
Some payments were externally managed and needed to be synchronized into the system for visibility, exports, and receipt access. Others needed to be created and maintained internally as controlled manual records. The system had to unify reporting, filtering, and operator workflows while still preserving a hard boundary between those two worlds.
That boundary became the main architectural decision: synced records would remain read-only, manual records would remain internally owned, and the admin would act as a serious operational tool rather than a fuzzy imitation of a full accounting platform.
The problem
The business needed one place to inspect outgoing payments, export them, retrieve receipts, and handle manual exceptions.
But the underlying data did not come from one clean source with one clean lifecycle.
There were two distinct realities:
- Externally managed expenses, synchronized from Revolut
- Manual internally managed payments, created through the admin or bulk-imported by CSV
The easy mistake here would have been to collapse everything into a single generic model and let the admin mutate it all. That would have looked flexible in the short term, but it would also have created ownership ambiguity, awkward sync logic, and a system that slowly drifted into doing jobs it should never have owned.
The real requirement was more disciplined: provide one reporting and admin surface, but keep the source boundaries visible and enforceable.
Why this was hard / constraints
This project sat in an awkward but very real engineering space: internal finance tooling where correctness, trust, and operator clarity matter more than flashy product behavior.
A few constraints made it harder:
- The system had to support multiple payment sources without flattening their different rules.
- Synced records needed to be visible and reportable, but not editable.
- Manual records needed full internal workflows, including creation, update, deletion, receipts, and import.
- Receipt handling had to work across both sources, even though ownership of those files was different.
- Bulk import had to be safe enough for finance operations, which meant idempotency, validation, and clear failure reporting.
- Automated sync and receipt retrieval needed concurrency controls to avoid overlapping work, duplicated writes, or inconsistent state.
- The admin UI had to feel like one coherent operational surface without misleading users into thinking all records behaved the same way.
In other words: the difficult part was not “integrating an API.” It was designing a system that made different ownership models usable together without becoming conceptually dishonest.
Architecture decisions
The most important decision was to unify reporting without unifying ownership.
1. Source-aware domain model
I modeled outgoing payments under one internal surface, but with an explicit source split:
revolutmanual
That sounds simple, but it was carrying real architectural weight. It meant the system could support shared filtering, exports, list views, and receipt access while still applying source-specific rules around mutation, sync, and lifecycle.
2. Read-only sync for externally managed records
Revolut-synced expenses were treated as read-only projections of an upstream system.
That was deliberate. I did not want the admin to become a second expense-management tool with half-owned records and confusing mutation rules. Syncing those records into the internal database enabled reporting, search, exports, and receipt access, but operational ownership stayed with the source system.
3. Manual flows as explicit internal ownership
Manual payments were a different category entirely. Those records were fully internal, which meant the system owned their CRUD lifecycle, receipt storage, validation, deletion behavior, and CSV import.
This kept manual handling available where the business needed it, but as a controlled exception path rather than a competing operational model.
4. Source-aware receipt handling
Receipts followed the same ownership logic as the payment records themselves.
- Manual receipts were uploaded and stored internally in MinIO.
- Revolut receipts were fetched from the upstream API and cached on demand.
That mattered because file handling often erodes architecture quietly. Here, it reinforced it.
5. Reliability by design, not cleanup later
The system included explicit locks, import idempotency, soft-delete and restore behavior, dry-run sync support, and queue-backed processing because this was not optional polish. It was part of making the tool operationally trustworthy.
What I built
I implemented the system across backend, frontend, storage, and queue workflows.
Backend
On the backend, I built a Laravel-based outgoing payments module with:
- a source-aware payments model
- separate account management for outgoing payment accounts
- Revolut sync services and jobs
- manual CRUD endpoints
- bulk CSV import job processing
- unified receipt download endpoints
- export and filtering endpoints for admin reporting
The backend model supported a shared reporting shape while preserving source-specific behavior. Revolut records were upserted by external ID. Manual imports used stable idempotency keys. Soft-deleted records could be restored when appropriate. Split rows were modeled separately when a synced expense had multiple allocation lines.
Frontend
On the frontend, I implemented a dedicated admin surface in Next.js with:
- a paginated outgoing payments table
- URL-driven filters
- export flow
- source-aware row actions
- manual payment create/edit/delete drawers
- import job handling
- sync controls
- outgoing account management
The UI deliberately unified visibility without flattening behavior. Operators could inspect all outgoing payments together, but the actions available on each row still reflected the record’s source and ownership.
Manual workflows
For manual operations, I built:
- manual account creation and management
- manual payment CRUD
- receipt upload, replacement, and deletion
- bulk CSV import with validation and job tracking
This gave finance/admin users a structured way to handle exceptions and internally owned payments without forcing everything through the external sync model.
Reliability and operational concerns
A system like this is only useful if operators trust it.
That trust came from a set of reliability decisions that were built into the design rather than layered on afterward.
Sync safety
Revolut sync ran through explicit services and queue jobs, with locking to prevent overlapping sync execution. Upserts were based on external IDs, and previously deleted synced records could be restored if they reappeared upstream.
Import idempotency
Manual CSV import used a stable import_key strategy so the same file could be reprocessed safely without creating duplicates. That mattered because finance workflows often involve correction and re-upload, not pristine one-shot imports.
File-atomic import behavior
The import process validated the whole file before writing rows. If any row was invalid, the job failed cleanly, produced an error report, and wrote nothing. That was a much safer operator experience than partial success with hidden inconsistencies.
Receipt handling with concurrency guards
Receipt access had different paths for manual and synced records, but both were exposed through one coherent admin action. For synced receipts, I used cache-on-demand behavior plus per-payment locking so concurrent requests would not trigger duplicate fetches or conflicting writes.
Soft-delete and restore semantics
Manual payments were soft-deleted rather than hard-removed. Synced records could also be soft-deleted when they disappeared from the sync window and restored if they reappeared later. That created a more operationally honest system than brittle hard deletes or silently disappearing history.
Operational support and diagnostics
The system included logging, queue-based job processing, explicit status tracking for import jobs, dry-run sync capability, and structured troubleshooting paths. Those are not glamorous features, but they are the difference between a demo and a production tool someone can rely on.
Why this design was maintainable
The design stayed maintainable because it refused to lie about ownership.
That decision showed up everywhere:
- in the database model
- in the API surface
- in the row actions exposed by the admin
- in receipt storage behavior
- in import logic
- in sync and restore semantics
A unified reporting surface often becomes dangerous when it quietly implies a unified lifecycle. I avoided that. Shared visibility did not mean shared mutation rules. Internal records and synced records could coexist without pretending to be the same kind of thing.
That made the system easier to reason about over time.
It also kept future change more manageable. New filters, exports, or reporting needs could build on the shared surface. Changes to sync behavior or manual workflows could stay source-specific. That is a much healthier place to be than an admin system where every exception leaks into every other workflow.
Key takeaways
This project is a good example of the kind of systems work I care about: the kind where architecture judgment matters more than just feature delivery.
A few takeaways stand out:
- Business-critical internal tools need clear ownership boundaries.
- Unification is valuable at the reporting layer, not necessarily at the lifecycle layer.
- Read-only sync is often a strength, not a limitation.
- Operator-facing systems need reliability features that match real workflow risk.
- Maintainability depends on enforcing the same architectural idea across backend, frontend, jobs, and storage.
The most important result was not that the system could list payments or import CSVs.
It was that the system became easier to trust.
Final note
This was not a generic admin feature set and it was not an “AI workflow” in disguise.
It was a serious internal systems project: designing a reliable outgoing payments surface across multiple sources, with clear boundaries, source-aware behavior, operational safeguards, and enough discipline to stay maintainable under real business use.
That is the kind of engineering work I want to be judged on: not just shipping features, but making complex operational systems clearer, safer, and more trustworthy.
