Personal finance product. 2025 to 2026. Author. Expo, React Native, Supabase, Wompi.
Capitalia
A personal finance app with real subscription billing: budgets, accounts and goals on Supabase, and a payment layer that moved from Stripe to Wompi without the schema having to care which one it is.
The problem
A subscription app has to charge people, and in Colombia that means Wompi rather than Stripe. The app was built on Stripe first, so the interesting work was not adding a second gateway. It was removing the assumption that there was only ever one.
I migrated the payment layer to Wompi in October 2025 and used the migration to generalise the schema instead of renaming it. Everything that touches a secret runs in a Supabase Edge Function; the device only ever invokes one.
Two gateways, one schema
The migration renamed stripe_customers to payment_customers and added a payment_provider column, so a second gateway is a row value rather than a second set of tables.
No payment secret on the device
Checkout, cancellation, status and webhooks are all Edge Functions. The app holds public keys only, and a stolen build gives an attacker nothing to charge with.
Authorisation lives in the database
Row Level Security on every sensitive table means a query cannot return another person's transactions even if a screen forgets to filter.
Features stay where they belong
A strict scope rule decides placement: used by two features it moves to shared, used by one it stays local. The structure does not drift as the app grows.
Architecture
Core service
Edge Functions
Checkout, cancellation, status, webhooks
Deno functions holding the gateway secret. create-wompi-checkout, cancel-subscription-wompi, get-subscription-status-wompi and wompi-webhooks, with the Stripe equivalents still present alongside them.
Decisions and the price I paid for them
Generalise the schema during the migration, not after
Moving to Wompi could have been a rename. Instead the customer table became payment_customers with an explicit payment_provider column, so the data model stopped encoding a vendor name.
Trade-off accepted
A rename migration against live tables is riskier than adding new ones, and two provider implementations now have to keep working rather than one.
Every secret-bearing call is an Edge Function
Creating a checkout on the client would put a secret key in a binary anyone can unpack, so the app can only invoke a function and read the result.
Trade-off accepted
Every payment path is a deployed, versioned function with its own logs and failure mode, instead of one SDK call.
Row Level Security instead of guards in screens
Authorisation in Postgres holds for every caller, including a future web client or a script, rather than depending on each screen remembering to filter by user.
Trade-off accepted
A policy bug is a database bug: harder to unit-test and slower to diagnose than a forgotten guard clause in application code.
One placement rule, no exceptions
Code used by two or more features moves to shared, code used by one stays inside that feature. Making it absolute is what keeps shared from becoming the folder everything drifts into.
Trade-off accepted
Promoting code is a deliberate act, so a small change occasionally costs a refactor it would not otherwise need.
Inside the build
-
Fig. 02 The migration that generalised the payment schema.
-
Fig. 03 Both gateways deployed side by side as functions.
-
Fig. 04 Feature-local structure, with a strict promotion rule.