Skip to main content
Complete work archive

Production / Fundraising SaaS

Funraisin

A fundraising platform running campaigns, donations, regular 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 raffle sale records from 21 to 1 in a manual test with 21 concurrent requests
  • Traced a PURL (personal campaign link) import that failed on its first attempt to CSV parsing of quoted and empty fields
  • 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

Stack: PHP, MySQL, Stripe, Salesforce, AWS S3, CodeIgniter, jQuery, Bootstrap

Inputs

Donation forms
Stripe and PayPal
Scheduled jobs
CSV and CRM imports
Fundraising SaaS

Outputs

Campaign pages
Regular giving schedules
Salesforce sync
Admin reporting
What this system takes in, and what reads from it.
Funraisin admin dashboard overview (figures masked).

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.

I joined Funraisin through SoftwareSeni in June 2025 and am still on it. The role is full-stack because the same change often spans donation logic, admin configuration, scheduled processing, and page templates.

Each client runs its own Funraisin site on its own domain, and I get access to one only when investigating an issue on it. With no platform-wide view of donation volume or revenue, the stories below report what I tested and observed, not production totals.

The codebase is CodeIgniter 3 with no automated test suite. A QA engineer has started adding Playwright end-to-end tests, but my own changes are verified by reproducing the issue on demo and bugfix branches, often with scripted dummy data or a charity's Stripe test keys, and attaching before-and-after evidence to the ticket. Where a story below says there is no regression test, that is why.

My scope

I owned

  • The concurrency fixes in the matched-giving and raffle payment paths.
  • PURL (personal campaign link) import behavior, plus its 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.

In one peer review, I flagged inconsistent donation-reference parameters in another engineer's change, then checked the follow-up before approving it.

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. 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. This was a manual check, not a load test: it covers a genuine concurrent submission and says nothing about sustained volume.

I later rebuilt the pattern from scratch, without client code, at github.com/imrdwi/matched-giving-lock. The rebuild adds what the production change did not have: an automated test that fires real concurrent requests.

Matched giving concurrency, before and afterBefore: two donations read the same pool balance and both insert a match, overcharging the sponsor. After: requests serialize through a per-pool lock around the remaining-balance check and insert.BeforeRequest ARequest BPool balancesame readInserts2 matches — over capBoth requests read the same remaining balance, so both insert a match.AfterRequestlock → check → insertone at a timeUnlock1 matchunder capA per-pool named lock serializes the balance check and the matched-donation insert.
Only the ordering changes. The same calculation runs on the same seven donation paths, one request at a time per pool.

Two raffle requests creating the same sale

Concurrency in a payment path

Problem

After the matched-giving fix, I checked the raffle sale path for the same race and found it there. 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 made creation atomic by claiming the temporary raffle sale record — deleting it and checking affected rows. The winner inserts the raffle sale. Losers see zero affected rows, wait 2 seconds, and look up the committed sale by order number instead of creating a duplicate.

Result

I reproduced the race with a script that replayed a completed payment's PayPal webhook payload as 20 concurrent requests alongside the genuine one, so 21 hit the path at once. Before the fix, it left 21 sale records: 1 from the genuine request and 20 from the replay. After the fix, it left 1. This was a manual replay check, not an automated regression guarantee. The result alone does not establish what happens if the winning request stalls or fails after claiming the temporary record. There is no automated race test yet.

Raffle sale concurrency, before and afterBefore: concurrent webhooks both create a sale via a fixed delay, leaving 21 sales for one purchase. After: the temporary sale record is claimed by deleting it; the winner inserts, losers wait 2 seconds and look up the committed sale by order number, so no duplicate is created.BeforeConcurrent webhooksFixed delay21 salesfor 1 purchaseA fixed delay hid the race without deciding which request created the record.AfterClaim recorddelete + affected rowswinner → insertloser → wait 2slookup by order no.1 saleDeleting the staged record decides the winner, so exactly one request inserts the sale.
The wait survives the change, but only for losers reading back a sale that already exists.

An import that failed once, then succeeded on retry

Data import

Problem

Advanced PURLs give each supporter a personal campaign link with pre-filled amounts, generated in bulk from a CSV import. An import failed on the first attempt and succeeded on retry. Reproducing it from the recording on the ticket, I found the failure in parsing the template the admin interface itself provides: quoted fields broke the parser, and an empty value shifted the remaining columns into the wrong fields.

Action

I rewrote the parsing to handle quoted fields and empty values, then ran other CSV samples through it to confirm they still parsed and that the duplicate-email check still held. I had added that check earlier: before insert, each row's email is compared with the campaign's existing records and with rows already read from the file, so a supporter is imported once per campaign even when an import runs again.

I also made expiry and usage mode configurable settings, made expired PURLs load their page and form normally, and added search to the admin interface, which was unusable once an account held thousands of records.

Result

The downloaded template imports on the first attempt, and running an import again does not duplicate supporters already in the campaign. I merged and deployed the fix and checked that the reported failure no longer occurred. Expired links open a normal page, and administrators can find a record without paging through the whole set.

A bulk PURL import in the campaign editor, with the usage, expiry, and destination-page controls around the generation workflow.

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 dollar handles (the preset donation-amount buttons) 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, and I fixed the reported fallback defects in the affected template paths.

Enabling Intelligent Giving replaces the manually configured handles with personalized defaults; switching it off restores the manual handles. In production, the data lake recalculates the recommendations daily; this preview verifies the configuration and rendering path.

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 pick up and reprocess the affected donations. 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

  • Initiative delivery

    Worked 6 of the 8 tickets in the Third Party Event Management initiative, covering registration capacity limits, open and closed registration flows, final-submission entry limits, and QR codes for ticket check-in and check-out. Worked 5 of the 6 tickets in Designated Giving, covering the designation hierarchy, donor-side designation selection, donation-form configuration, and the backend for multi-split support; the remaining ticket was design work.

  • 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 and translation key coverage.

  • Regular giving upsell

    Rolled the upsell across four page templates, including the settings, preview behavior, and iconography each template handled differently.

What they wrote about me

The Funraisin AU team named me their Legend of the Month for November 2025. From the announcement, quoted as written:

Rizki's been performing well above expectations and actively demonstrates being better than yesterday, not just technically but in team culture and collaboration. He's proactive and challenges ideas constructively, making him a valuable voice in discussions.

Funraisin pod team

The same announcement recorded that Funraisin's Head of Engineering highlighted my delivery on the regular-giving upsell and my pull-request review for the pod.

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 added a lock only where I could show the existing code taking money it should not have taken. A lock in a request path adds overhead on every request, so I have kept it at the payment boundary rather than applying it more widely.

Two changes I got wrong

A PHP 8 function on PHP 7.4 sites. While building guest personalisation tags for ticket QR codes, I added a change to funraisin_helper that relied on a function only available from PHP 8. Some client sites still run PHP 7.4, and the helper broke on them. The change was taken back out; I re-applied it on a fresh branch from develop in a form that runs on both versions, and checked it on PHP 7.4 and PHP 8.3 before opening the pull request. Checking a change against the oldest PHP version a client still runs is now part of preparing a pull request.

A merge conflict I did not reread. Another developer resolved a merge conflict on my branch, and I did not read the resolution closely. A bracket was left wrong, and test sites broke once they pulled the latest develop. It was caught before release. Since then I read every resolved conflict, mine or someone else's, before the branch moves on.

This is an active assignment. Source and customer data are Funraisin's; the public matched-giving-lock rebuild contains none of either.