Not every market has easy access to Stripe. In a lot of places the real payment rails are bank transfer and mobile wallets, confirmed by a human. That changes the architecture more than you would think: without a gateway webhook, your database is the source of truth for payment state, and you have to design it that way deliberately.
The order state machine
Five states, one direction, no shortcuts:
pending → awaiting_payment → payment_submitted → confirmed → fulfilled
↓
rejected
-
pending— cart submitted, nothing else -
awaiting_payment— customer has the transfer details -
payment_submitted— customer says they paid and gave a reference -
confirmed— a human matched it against the account -
rejected— no match found
The important rule: only confirmed unlocks anything. payment_submitted is a customer claim, not a fact. Treating it as one is how you get taken.
Schema
create table orders (
id uuid primary key default gen_random_uuid(),
reference text unique not null,
status text not null default 'pending',
customer_name text not null,
customer_phone text not null,
total_amount numeric(10,2) not null,
payment_method text,
payment_reference text,
confirmed_at timestamptz,
confirmed_by uuid references auth.users(id),
created_at timestamptz default now()
);
create table order_items (
id uuid primary key default gen_random_uuid(),
order_id uuid references orders(id) on delete cascade,
product_id uuid references products(id),
quantity int not null check (quantity > 0),
unit_price numeric(10,2) not null
);
unit_price is copied onto the line item, not read through the product. Prices change; an order is a record of what was agreed at the time.
The short human-readable reference matters more than it looks. Customers type it into a transfer note and read it over the phone. A UUID is unusable for that.
Row Level Security is not optional
Supabase exposes your tables directly to the browser. The anon key is public — it is in your JavaScript. Without RLS, anyone can read every order in your database.
alter table orders enable row level security;
create policy "insert own order" on orders
for insert to anon with check (status = 'pending');
create policy "staff read all" on orders
for select to authenticated using (
exists (select 1 from staff where user_id = auth.uid())
);
Note what the insert policy does: an anonymous visitor can create an order, but only in pending. They cannot post one that is already confirmed. Every status transition after that happens server-side under a service role, never from the client.
Total on the server
The client sends product ids and quantities. It does not send a total.
const { data: products } = await supabase
.from('products')
.select('id, price')
.in('id', items.map((i) => i.product_id));
const total = items.reduce((sum, item) => {
const product = products.find((p) => p.id === item.product_id);
if (!product) throw new Error('Unknown product');
return sum + product.price * item.quantity;
}, 0);
Anything the browser sends is a suggestion. If a price can be set client-side, it will be.
Idempotency on submit
A customer on a slow connection taps the button three times. Without protection that is three orders and three confused phone calls.
Generate an idempotency key when the checkout form mounts, send it with the order, and put a unique constraint on it. The second and third submit hit the constraint and return the existing order instead of creating a new one.
The admin panel is the product
With a gateway, a webhook flips the status. Without one, a person does. So that screen deserves real attention:
- Orders sorted oldest first — the customer waiting longest gets served first.
- The payment reference copyable in one tap, because they are cross-checking it against a bank statement.
- Confirm and reject as separate, deliberate actions with a note field.
- Every transition written to an audit table with a user id. When something is disputed weeks later, "who confirmed this and when" is the only question that matters.
What you gain
No processing fees, no chargebacks, no gateway approval process. That is a genuinely good trade for a small catalogue in a market where cards are not the norm.
What you pay for it is latency. An order sits until a human looks at it, so set the expectation on the confirmation screen: tell the customer the window, and send something when the status changes. Most complaints in this model are not about the payment method, they are about silence.
Top comments (0)