<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alan Maizon</title>
    <description>The latest articles on DEV Community by Alan Maizon (@alanmaizon).</description>
    <link>https://dev.to/alanmaizon</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1466829%2F6e863d71-3b45-4c83-b0d6-ea868450c8db.png</url>
      <title>DEV Community: Alan Maizon</title>
      <link>https://dev.to/alanmaizon</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alanmaizon"/>
    <language>en</language>
    <item>
      <title>Turning a wedding gift list into a charity-giving platform</title>
      <dc:creator>Alan Maizon</dc:creator>
      <pubDate>Sat, 06 Jun 2026 12:53:08 +0000</pubDate>
      <link>https://dev.to/alanmaizon/turning-a-wedding-gift-list-into-a-charity-giving-platform-kh5</link>
      <guid>https://dev.to/alanmaizon/turning-a-wedding-gift-list-into-a-charity-giving-platform-kh5</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — A real wedding asked guests for charity donations instead of gifts. That single-couple prototype became &lt;em&gt;Love That Gives Back&lt;/em&gt;: a multi-tenant platform where anyone can spin up a registry for any celebration, guests donate to &lt;strong&gt;verified&lt;/strong&gt; charities through Stripe, leave a moderated public message, and every euro is tracked on an append-only ledger. The original wedding is the live flagship campaign.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it started
&lt;/h2&gt;

&lt;p&gt;A year ago, instead of a traditional gift list, a couple — Anna &amp;amp; Alan — asked their wedding guests to donate to three charities that mattered to them: &lt;strong&gt;Mary's Meals&lt;/strong&gt;, &lt;strong&gt;Operation Smile&lt;/strong&gt;, and &lt;strong&gt;Xingu Vivo&lt;/strong&gt; (a grassroots Amazon-basin movement). Each choice had a story: a visit to the Scottish Highlands where Mary's Meals began; a brother born with a cleft palate; a wedding ring "paid for" with a donation instead of cash.&lt;/p&gt;

&lt;p&gt;The v0 site did the job, but it was held together with tape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Guests transferred money manually over Revolut/bank, and an admin clicked &lt;strong&gt;"confirm"&lt;/strong&gt; by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bank details were stored in plaintext&lt;/strong&gt; on a model.&lt;/li&gt;
&lt;li&gt;The guestbook was a static CSV.&lt;/li&gt;
&lt;li&gt;Charts were server-rendered matplotlib PNGs dragging in ~8 heavy dependencies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It worked for &lt;em&gt;one&lt;/em&gt; couple. The interesting question was: what if &lt;strong&gt;anyone&lt;/strong&gt; could do this — for a birthday, a memorial, any celebration — without the manual money handling and without a platform ever touching a bank number?&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;modular monolith&lt;/strong&gt; that treats money with the seriousness it deserves.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;v0 prototype&lt;/th&gt;
&lt;th&gt;v2 platform&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tenancy&lt;/td&gt;
&lt;td&gt;one hardcoded couple&lt;/td&gt;
&lt;td&gt;many &lt;strong&gt;Campaigns&lt;/strong&gt; + &lt;strong&gt;Charities&lt;/strong&gt;, row-scoped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Money&lt;/td&gt;
&lt;td&gt;manual transfer, admin clicks "confirm"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Stripe Connect&lt;/strong&gt; destination charges → verified charity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bank data&lt;/td&gt;
&lt;td&gt;plaintext &lt;code&gt;account_number&lt;/code&gt; on a model&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;never stored&lt;/strong&gt; — a Stripe account id + capability flags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Split&lt;/td&gt;
&lt;td&gt;50% couple / 50% charity&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100% to charity&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source of truth&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;Donation&lt;/code&gt; row&lt;/td&gt;
&lt;td&gt;append-only &lt;strong&gt;&lt;code&gt;LedgerEntry&lt;/code&gt;&lt;/strong&gt;, reconcilable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guestbook&lt;/td&gt;
&lt;td&gt;static CSV, unmoderated&lt;/td&gt;
&lt;td&gt;moderated &lt;code&gt;Message&lt;/code&gt; API (approved-only public)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhooks&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;signature-verified, &lt;strong&gt;idempotent&lt;/strong&gt; (deduped by event id)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PII&lt;/td&gt;
&lt;td&gt;donor email in public responses&lt;/td&gt;
&lt;td&gt;stripped for non-staff; JSON-only API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; Django 5 + DRF (Postgres in prod, SQLite locally), React 19 + Vite on the front, Stripe-hosted Checkout + Connect for payments, all deployable to a deliberately lean AWS footprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invariants I refused to break
&lt;/h2&gt;

&lt;p&gt;Money software is mostly about what you &lt;em&gt;won't&lt;/em&gt; let happen. I wrote these down on day one and built guardrails around them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Money can only ever reach a &lt;strong&gt;verified&lt;/strong&gt; &lt;code&gt;Charity&lt;/code&gt;. No payouts to individuals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never&lt;/strong&gt; store raw bank/card data — payout identity is a Stripe account id, full stop.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LedgerEntry&lt;/code&gt; is the source of truth for money — &lt;strong&gt;append-only&lt;/strong&gt;, reconciled daily against Stripe. You never mutate money state by editing a &lt;code&gt;Donation&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;All public user content is &lt;strong&gt;moderatable&lt;/strong&gt; and consent-gated.&lt;/li&gt;
&lt;li&gt;Row-level multitenancy enforced in DRF &lt;code&gt;get_queryset&lt;/code&gt;, not just in serializers.&lt;/li&gt;
&lt;li&gt;No PII (donor email) in public API responses.&lt;/li&gt;
&lt;li&gt;PCI: stay &lt;strong&gt;SAQ-A&lt;/strong&gt; — Stripe-hosted Checkout only; card data never touches the backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency&lt;/strong&gt; on every payment op; verify &lt;em&gt;and&lt;/em&gt; dedupe webhooks by event id.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The one that shaped the most code is #3. A donation isn't "real" because a row says &lt;code&gt;confirmed&lt;/code&gt; — it's real because a signed Stripe webhook arrived, was deduped, and wrote a ledger entry inside the same transaction as an &lt;code&gt;OutboxEvent&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# same DB transaction: ledger + outbox, drained later for receipts/emails
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;LedgerEntry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;donation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;donation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="n"&gt;OutboxEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;donation.confirmed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{...})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A worker (&lt;code&gt;manage.py drain_outbox&lt;/code&gt;) drains the outbox to send receipts and thank-you emails. If email delivery is down, the money record is still correct and the side effects retry — no lost receipts, no double charges.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hardest lesson: don't test someone else's UI
&lt;/h2&gt;

&lt;p&gt;My end-to-end test originally drove &lt;strong&gt;Stripe's hosted Checkout page&lt;/strong&gt; in CI with Playwright — typing the &lt;code&gt;4242…&lt;/code&gt; test card into Stripe's iframes. It was chronically flaky, and eventually I understood &lt;em&gt;why&lt;/em&gt;: Stripe flags headless/datacenter traffic with an agent-identity challenge and never completes the charge. Automating that page in CI tests &lt;strong&gt;Stripe&lt;/strong&gt;, not my app — and it's PCI SAQ-A territory I don't own.&lt;/p&gt;

&lt;p&gt;So I decoupled it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CI test&lt;/strong&gt; fills my donate form, asserts the backend created a valid &lt;code&gt;checkout.stripe.com&lt;/code&gt; session (proving params + idempotency), then confirms the donation through the &lt;strong&gt;real webhook code path&lt;/strong&gt; via a &lt;code&gt;DEBUG&lt;/code&gt;-and-&lt;code&gt;E2E_TEST_HOOKS&lt;/code&gt;-gated endpoint, and finishes the browser flow: pending guestbook → host approves → public.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live UI walk&lt;/strong&gt; (the full card entry) stays a local-only &lt;code&gt;@live&lt;/code&gt; test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CI went from a multi-minute flake-fest to a deterministic ~4s run. The principle generalizes: &lt;strong&gt;at your trust boundary, assert the contract you control, not the third party's UI.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping it without lighting money on fire
&lt;/h2&gt;

&lt;p&gt;The deploy target is intentionally cheap. Using the AWS pricing tooling against my Terraform, the whole stack lands around &lt;strong&gt;$50–60/month&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ECS Fargate (0.25 vCPU / 0.5 GB) &lt;strong&gt;in public subnets — no NAT Gateway&lt;/strong&gt; (that alone saves ~$32/mo)&lt;/li&gt;
&lt;li&gt;RDS &lt;code&gt;db.t4g.micro&lt;/code&gt; PostgreSQL, Single-AZ ($0.017/hr)&lt;/li&gt;
&lt;li&gt;One ALB, S3 + CloudFront for the SPA, SSM Parameter Store for secrets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The budget guardrails are explicit non-goals in the repo: no NAT, no Aurora Serverless v2, no second ALB. I wrapped the whole go-live into one idempotent script (&lt;code&gt;scripts/phase1-golive.sh&lt;/code&gt;) with subcommands — &lt;code&gt;infra&lt;/code&gt;, &lt;code&gt;acm&lt;/code&gt;, &lt;code&gt;ssm&lt;/code&gt;, &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;migrate&lt;/code&gt;, &lt;code&gt;frontend&lt;/code&gt;, &lt;code&gt;webhook&lt;/code&gt;, &lt;code&gt;verify&lt;/code&gt; — that read straight from Terraform outputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;With the flagship seeded, the "real money plumbing" is visible end to end:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Home&lt;/strong&gt; — hero + a guestbook carousel of 27 &lt;em&gt;real&lt;/em&gt; guest messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics&lt;/strong&gt; — Money raised, per-charity bars, goal progress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin&lt;/strong&gt; — Donations, &lt;strong&gt;read-only Ledger entries&lt;/strong&gt;, Webhook events. (These two tables are the whole thesis in one screen.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Self-serve &lt;strong&gt;cover image upload&lt;/strong&gt; and &lt;strong&gt;co-host invites&lt;/strong&gt; in the registry wizard.&lt;/li&gt;
&lt;li&gt;Move the platform-admin &lt;strong&gt;charity verification queue&lt;/strong&gt; out of Django admin and into the SPA.&lt;/li&gt;
&lt;li&gt;Per-campaign analytics for hosts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it / read it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/alanmaizon/love" rel="noopener noreferrer"&gt;https://github.com/alanmaizon/love&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="http://www.lovethatgivesback.com" rel="noopener noreferrer"&gt;www.lovethatgivesback.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you take one thing from this: when you build on top of a payment processor, let &lt;em&gt;their&lt;/em&gt; hosted surface own the card data and the compliance — and make your own system provably correct with an append-only ledger, idempotent webhooks, and a transactional outbox.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>githubchallenge</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
