athanasios papadopoulos

Closed source

Avgoro

A marketplace for local egg producers in Greece, on the web and as a React Native app.

period:
2026
status:
in development
  • PostgreSQL
  • SQL
  • Supabase
  • Stripe Connect
  • TypeScript
  • JavaScript
  • React Native
  • Firebase
  • Google Sign-In
  • Deno
  • Netlify
  • Resend
  • Plausible
  • ipapi.co
The website: find producers in your area, order, and follow the order.
The app: the same order on the buyer's phone and the producer's.

On Avgoro, producers set their own prices, buyers search by area, and Stripe Connect pays each farm.

How it’s built

The website is plain HTML, JavaScript and CSS, with no framework. The app is React Native 0.87 with 14 screens, including KYC and courier tracking. Both talk to Supabase with a public key, so PostgreSQL decides what each user can do.

Web: the marketplace, searching for producers by area. (full size)
Web: the marketplace, searching for producers by area.
Web: the producer dashboard, with an order's commission and net income. (full size)
Web: the producer dashboard, with an order's commission and net income.
App: the marketplace. (full size)
App: the marketplace.
App: a producer's page. (full size)
App: a producer's page.
App: further down, the products, delivery details and reviews. (full size)
App: further down, the products, delivery details and reviews.
App: Stripe's native payment sheet. (full size)
App: Stripe's native payment sheet.
App: the producer's orders, with one sent by courier. (full size)
App: the producer's orders, with one sent by courier.

How security works

The rules are in 44 SQL migrations. The server works out every price from the database, and admin actions need two-factor authentication.

Interactive demo

Try to cheat the checkout

Edit the checkout request or try an attack, and see what the server does with it. This runs a copy of the checkout rules, tested against the real code (below, underPrices worked out on the server).

What went wrong

Two migrations both defined recompute_order, the function that works out an order’s fees, and the one that ran last won. One copy ignored each farm’s own delivery fee and charged the platform default. The other had lost its pending check, so it could rewrite a paid order’s amounts. Now there is one definition, and the tests below check both cases.

How checkout works

  1. 01 The public API key
  2. 02 Row-level security
  3. 03 Prices worked out on the server
  4. 04 Stock locks in a fixed order
  5. 05 Fees in the database
  6. 06 Testing the fees
  7. 07 Order status through one function

The public API key

Both clients connect to Supabase with the anon key, which anyone can read from the app, so row-level security has to guard the data. This lock stops two tabs refreshing the session at once and signing the user out.

assets/src/00-config.js, cross-tab refresh lock
async function avgAuthLock(name, acquireTimeout, fn) {
  if (!(navigator.locks && navigator.locks.request)) return await fn();
  let started = false;
  try {
    return await navigator.locks.request(name, { mode: 'exclusive' }, async () => {
      started = true;
      return await fn();
    });
  } catch (e) {
    if (started) throw e;       // fn itself failed: don't run it twice
    return await fn();          // the lock could not be acquired
  }
}

Row-level security

Only the producer who owns a KYC row, or an admin, can read it. The table has no write policies, so writes can only go through the submit_kyc function.

kyc-pii-lockdown-v2.sql, read policy on producer KYC
CREATE POLICY "Owner or admin reads producer_kyc"
  ON public.producer_kyc FOR SELECT TO authenticated
  USING (
    public.is_admin()
    OR producer_id IN (SELECT id FROM public.producer_profiles WHERE user_id = auth.uid())
  );

Prices worked out on the server

The request only names products and quantities. create-checkout reads prices and fees from the database (byId holds those rows) and never reads a price the client sends.

create-checkout/index.ts, prices from the database, quantities from the request
const groups = new Map<string, Grp>();
for (const [pid, qty] of requested) {
  const p = byId.get(pid)!;
  const g = groups.get(p.producer_id) ?? { lines: [], subtotalCents: 0, deliveryCents: feeFor(p.producer_profiles) };
  g.lines.push({
    product_id: p.id, producer_id: p.producer_id, farm_name: p.producer_profiles.farm_name,
    type: p.type, unit: p.unit, price: Number(p.price), qty,
  });
  g.subtotalCents += cents(Number(p.price)) * qty;
  groups.set(p.producer_id, g);
}

On mobile: Stripe payment sheet

The app calls the same function, then opens Stripe's native payment sheet with the client secret it gets back.

CheckoutScreen.js, initPaymentSheet and presentPaymentSheet (one line cut)
const {error: initError} = await initPaymentSheet({
  paymentIntentClientSecret: out.clientSecret,
});

if (initError) {
  showToast('Σφάλμα: ' + initError.message);
  setSubmitting(false);
  return;
}

const {error: presentError} = await presentPaymentSheet();
setSubmitting(false);

Stock locks in a fixed order

Each order line locks its product's row to check stock. Two checkouts locking the same products in opposite orders could deadlock, so the lines are sorted by product id first.

create-checkout/index.ts, sorted stock reservation
// Sorted by product_id so concurrent checkouts take stock locks in the
// same order (no deadlocks). The enforce_order_item_price trigger
// re-derives price, producer, farm name, type and unit from products.
const sortedLines = [...g.lines].sort((a, b) => (a.product_id < b.product_id ? -1 : 1));
const { error: itemsErr } = await supabaseAdmin
  .from("order_items").insert(sortedLines.map((l) => ({ ...l, order_id: order.id })));
if (itemsErr) {
  if (/insufficient_stock/.test(itemsErr.message)) {
    throw Object.assign(new Error("insufficient_stock"), { userFacing: true });
  }
  throw new Error(`items insert: ${itemsErr.message}`);
}

Fees in the database

A trigger runs recompute_order when a pending order's lines change, using the farm's own delivery fee if it has one. Paid orders are left alone, because refunds use the stored total.

security-hardening-2026-09b.sql, recompute_order
CREATE OR REPLACE FUNCTION public.recompute_order(p_order_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
  s public.platform_settings;
  o public.orders;
  sub numeric;
  rate numeric;
  fee numeric;
BEGIN
  SELECT * INTO o FROM public.orders WHERE id = p_order_id;
  IF o.id IS NULL OR o.status IS DISTINCT FROM 'pending' THEN
    RETURN;
  END IF;
  SELECT * INTO s FROM public.platform_settings WHERE id = 1;

  SELECT COALESCE(SUM(line_price), 0) INTO sub FROM public.order_items WHERE order_id = p_order_id;
  rate := CASE WHEN o.order_type = 'b2b' THEN COALESCE(s.commission_b2b, 0) ELSE COALESCE(s.commission_b2c, 0) END;

  -- Per-farm flat delivery fee (producer-ships: the farm pays its own courier).
  -- create-checkout makes one order per producer, so every line in this order
  -- belongs to the same farm and the first match is it. NULL = platform default.
  SELECT pp.delivery_fee INTO fee
    FROM public.order_items oi
    JOIN public.producer_profiles pp ON pp.id = oi.producer_id
   WHERE oi.order_id = p_order_id
   LIMIT 1;
  fee := COALESCE(fee, s.delivery_fee, 0);

  UPDATE public.orders SET
    subtotal     = sub,
    commission   = round(sub * rate, 2),
    delivery_fee = fee,
    total        = sub + fee
  WHERE id = p_order_id;
END;
$$;

Testing the fees

The test for the bug above. It sets an odd €7.13 fee on a farm and checks that a pending order uses it, then marks the order paid, changes the fee and checks nothing moves.

security-rls-tests.sql, fee checks on a probe order
-- An odd fee no platform setting would hold, so a match can only come from the farm.
UPDATE public.producer_profiles SET delivery_fee = 7.13 WHERE id = v_prod.producer_id;
INSERT INTO public.order_items (order_id, product_id, qty) VALUES (v_order, v_prod.id, 1);

SELECT * INTO o FROM public.orders WHERE id = v_order;
PERFORM pg_temp.expect(o.delivery_fee = 7.13 AND o.total = o.subtotal + 7.13,
  'a pending order charges the farm''s own delivery fee');

UPDATE public.orders SET status = 'paid' WHERE id = v_order;
UPDATE public.producer_profiles SET delivery_fee = 2.00 WHERE id = v_prod.producer_id;
PERFORM public.recompute_order(v_order);
SELECT * INTO o FROM public.orders WHERE id = v_order;
PERFORM pg_temp.expect(o.delivery_fee = 7.13, 'recompute_order leaves a paid order''s amounts alone');

Order status through one function

Clients can't write the status column. set_order_status checks each change: only the payment webhook marks an order paid, and a paid order is cancelled by refunding it.

security-hardening-2026-09b.sql, checks in set_order_status
IF v_current = p_status THEN
  RETURN;  -- idempotent no-op
END IF;

IF p_status = 'cancelled' AND v_current <> 'pending' THEN
  RAISE EXCEPTION 'paid_order_needs_refund'
    USING ERRCODE = 'check_violation',
          HINT = 'A paid order is cancelled by refunding it (create-refund).';
END IF;

-- Admins may correct mistakes between fulfilment steps, but never mark an
-- order paid (only the payment webhook does), reopen a cancelled order
-- (its stock was already released) or send it back to pending.
IF public.is_admin() THEN
  IF v_current = 'cancelled' OR p_status IN ('pending', 'paid') THEN
    RAISE EXCEPTION 'Illegal status change: % -> %', v_current, p_status;
  END IF;
  UPDATE public.orders SET status = p_status WHERE id = p_order_id;
  RETURN;
END IF;

IF NOT public._is_legal_transition(v_current, p_status) THEN
  RAISE EXCEPTION 'Illegal status transition: % -> %', v_current, p_status;
END IF;

On mobile: Push notifications

Status changes reach the app as Firebase push messages. Each carries the order number and its new status. When a parcel ships, the buyer's message also gives the courier and the tracking number. A tap opens the farm's dashboard or the buyer's tracking screen, which reads the order with the user's own session.

pushNotifications.js, setupForegroundHandler
export function setupForegroundHandler(onNotification) {
  if (!fbMessaging || !fbFns) return () => {};
  try {
    return fbFns.onMessage(fbMessaging, async remoteMessage => {
      if (onNotification) onNotification(remoteMessage);
    });
  } catch {
    return () => {};
  }
}