Production / Fundraising SaaS
Funraisin
A fundraising platform running campaigns, donations, recurring giving, events, and ticketing for charities in Australia and the UK.
- Relationship
- Client assignment · SoftwareSeni / Funraisin
- Role
- Full-stack Engineer
- Period
- Jun 2025 - Present
At a glance
- Stopped concurrent donations from overrunning a sponsor's matched-giving cap across seven donation paths
- Cut duplicate raffle sales from 21 records to 1 under 21 concurrent requests
- Made a partially failed PURL import safely repeatable instead of conflicting with its own partial state
- Rolled Intelligent Giving across four template generations behind a per-platform switch
- Traced donations stuck on the wrong campaign to a timestamp the Salesforce sync never saw change
- Took assigned work through implementation; planning, review, and release sat with the pod
Stack: PHP, CodeIgniter, MySQL, Stripe, jQuery, Bootstrap, AWS S3, Salesforce
Inputs
Outputs
What I built
Worked across payment correctness and backend feature delivery in a mature fundraising platform, covering intelligent giving, regular giving, matched giving, raffles, Stripe flows, scheduled jobs, Salesforce synchronization, and ticketing.
System context
Funraisin is a fundraising platform for charity events, peer-to-peer campaigns, donations, ticketing, and community fundraising. It runs on CodeIgniter with MySQL, jQuery and Bootstrap.
The constraints matter more here than the stack does. The system encodes years of fundraising rules, and live campaigns depend on those rules behaving exactly as they do today. It has also accumulated template generations: legacy templates, a classic builder, a visual builder, and the current donation block. A setting frequently has to exist at both platform and event level, and render correctly in all four.
Many tickets touch money: donations, matched giving, regular giving, refunds, receipting, and tax. For payment-related work, correctness under concurrency and compatibility with existing configuration are recurring constraints.
SoftwareSeni assigned me to Funraisin in June 2025. My role is full-stack because the same change often spans donation logic, admin configuration, scheduled processing, and page templates.
My scope
I owned
- The concurrency fixes in the matched-giving and raffle payment paths.
- PURL import behavior, expiry and usage modes.
- The Intelligent Giving rollout across four template generations, behind a per-platform switch.
- A long run of payments, ticketing, and sync defect work.
- Peer review of the pod's pull requests, assigned as scheduled work alongside the ticket queue.
I contributed to
- The regular-giving upsell across four page templates.
- Ticketing work around ticket-holder and entrant record linking.
Team-owned
I take assigned work through planning, implementation, review, and release. Designs, priorities, and final release decisions are shared across the pod.
Key engineering work
Matched giving could pay past its cap
Concurrency in a payment path
Problem
Matched giving lets a sponsor match donations up to a cap, and strict handling is meant to stop matching once the cap is reached. It did not hold under concurrency. Two donations submitted at the same instant could both read the remaining match pool before either had written its own match, so both matched and the sponsor was charged past the cap.
Action
I moved the calculation into the model used by donation pages, then added a per-pool MySQL named lock around the calculation and matched-donation insert. I applied the lock across seven donation paths and wrapped each use in try/finally so it releases when processing succeeds or throws.
Result
Matched-giving requests for the same pool now pass through one locked calculation and insert path. This was a manual check, not a load test. I submitted two donations to the same pool at the same moment from separate browser sessions, then read the donation rows back to see that one match calculation had landed and the total had stayed under the sponsor's cap. That covers a genuine concurrent submission and says nothing about sustained volume.
Two raffle requests creating the same sale
Concurrency in a payment path
Problem
Concurrent raffle requests could both try to create the sale linked to the same purchase. A fixed delay hid the race without controlling which request created the record.
Action
I added a database transaction and row-level lock around sale creation. When another request owns the creation path, bounded polling looks for the resulting sale instead of creating a duplicate.
Result
Competing requests now resolve to one sale through a controlled creation path. I reproduced the race with a script that replayed a completed payment's PayPal webhook payload as 20 concurrent requests. Before the fix that left 21 sale records: 1 from the genuine request and 20 from the replay. After the fix it left 1. Duplicates that had only become less frequent would still have shown up in that count.
An import that failed once, then succeeded on retry
Idempotency
Problem
Advanced PURLs give each supporter a personal campaign link with pre-filled amounts, generated in bulk from an import. The import failed on the first attempt and succeeded on the second. The first attempt left partial state behind, causing a retry to behave differently from a clean first run. The import path was not safely repeatable.
Action
I worked the import path and record hashing so bulk generation could safely resume after a partial run instead of treating a retry as a fresh import. Existing records from the previous attempt were recognized and reused, allowing subsequent attempts to continue processing the remaining data without conflicting with partial state.
I also made expiry and usage mode configurable, not implicit, made expired PURLs load their page and form normally instead of erroring, and added search to the admin interface, which was unusable once an account held thousands of records.
Result
Imports became safely repeatable after a partial run. Using the same file, I retried against the partial state and confirmed that existing hashed records were reused while processing continued for the remaining records without conflicts. Expired links degrade to a normal page instead of an error, and administrators can find a record without paging through the whole set.

One feature, four template generations
Safe rollout
Problem
Intelligent Giving suggests donation amounts. The logic is small, but it had to reach every donation form: platform-level and event-level settings rendering correctly across all four template generations. Releasing everywhere at once would have changed donation-form behavior on every platform simultaneously.
Action
I implemented the settings across that matrix and put the feature behind a beta switch so it could be enabled per platform instead of globally. Most of the follow-up work was configuration failing on one specific path. Defaults did not update, a preview did not reflect a change, and handles did not resolve when an event relied on platform defaults.
Result
Each platform could enable the feature independently. I verified that the disabled preview preserved its manually configured handles and that the enabled preview resolved Intelligent Giving defaults, while reported fallback defects were fixed in the affected template paths.


A sync that could not see a change that had happened
Integration debugging
Problem
A client syncing to Salesforce found donations staying attached to the wrong campaign after a fundraiser was moved to a new team. Nothing errored, and the platform's own screens were correct.
Action
Moving a fundraiser's donations updated the team reference on those records but did not touch their last-updated timestamp. The Salesforce sync uses that timestamp to decide what has changed, so from its side nothing had. Finding it meant reasoning about how change detection worked, not reading a stack trace.
Result
Updating the fundraiser's team now also updates the change signal consumed by the Salesforce synchronization path. The next sync can therefore reprocess the affected donations instead of leaving them attached to the previous campaign. I never watched a sync reprocess those donations. I confirmed that the timestamp gets updated, and the reprocessing follows from how the sync decides what has changed.
Other contributions
Payments and Stripe
Split payments stuck on a processing screen, incorrect tax on tickets bought from a dashboard, wallet buttons rendering regardless of platform settings, and a donation form returning a 500 on stale payment-intent references.
Ticketing
Ticket-holder records writing to one table but not its pair, period identifiers for accurate receipting, QR codes for check-in, and age validation against event or purchase date.
Globalization
Date-picker format handling across the platform, translation key coverage, and a bug where French apostrophes broke the JavaScript handling profile image uploads.
Regular giving upsell
Rolled the upsell across four page templates, including the settings, preview behavior, and iconography each template handled differently.
Technical decisions
CodeIgniter and jQuery would not be my choice for a new product, but replacing them was not the assigned problem. Years of fundraising rules sit in the existing code, so a change starts by tracing the current behavior and keeping the established defaults working.
I have only added a lock or a transaction where I could show the current code taking money it should not have taken. Pessimistic locking in a request path costs something on every request, so I have kept it at the payment boundary rather than applying it more widely.
Engineering takeaway
Live campaigns depend on the behavior that is already there, so a fix has to solve the reported problem without changing what existing events rely on. After the matched-giving fix I double-checked the raffle sale path for the same race and found it there as well. Checking the sibling paths is now part of how I close a concurrency fix.
This is an active assignment. Source and customer data are Funraisin's.