BetaMaShop is in public beta. We improve it continuously, and your feedback shapes what comes next.

Commerce tables

Data & backend referenceEnglish· 8 min read· Updated July 01, 2026

The commerce tables in your generated backoffice: orders, customers, coupons, shipping, and payment providers and transactions. Column types, the order state machine, and the RLS that gates every row.

Reference

This page documents the commerce tables MaShop creates in your own Supabase project: orders, customers, coupons, shipping, and payment providers and transactions. Every table here lives in the public schema of the database MaShop provisions under your account. You own the rows; MaShop never reads them. For the catalog side (products, variants, categories, inventory) see Catalog tables. For the security model see Row Level Security and RPCs.

How money and ownership work

Every monetary column is a bigint of minor units (cents), never a float. A price of 19.99 USD is stored as 1999. The orders.currency and payment_transactions.currency columns default to 'USD' and hold an ISO 4217 code. Render cents to a display string in your own code; the database stays integer-exact.

Every commerce table has Row Level Security enabled. None of them are readable by the anonymous storefront role. Only signed-in staff reach these rows: any staff role can read, and the owner, admin, and editor roles can write. Two tables tighten writes to owner and admin only: payment_providers and shipping_carriers, because they hold encrypted credentials.

Cents, not dollars

Reading total_cents as 1999 and dividing by 100 in your view layer is correct. Storing 19.99 directly is not: the column type is bigint and will round or reject a decimal.

customers

One row per buyer. The email column is citext and UNIQUE, so a buyer is deduplicated case-insensitively. Addresses are stored as jsonb blobs (billing_address, shipping_address), each defaulting to {}.

ColumnTypeNotes
iduuidPrimary key, gen_random_uuid()
emailcitextNOT NULL, UNIQUE
full_nametextNullable
phonetextNullable
billing_addressjsonbDefaults to {}
shipping_addressjsonbDefaults to {}
accepts_marketingbooleanDefaults to false
total_spent_centsbigintAggregate, kept fresh by an RPC
orders_countintegerAggregate, kept fresh by an RPC
created_attimestamptzDefaults to now()

The two aggregate columns are not maintained by triggers. They are recomputed on demand by the recompute_customer_aggregates(p_customer_id uuid) RPC, which sums total_cents across the customer's orders whose status is paid or fulfilled and writes back both total_spent_cents and orders_count. The backoffice calls it through supabase.rpc('recompute_customer_aggregates',...). The RPC requires at least the viewer role.

orders

One row per order. Each order carries its own money breakdown in cents, a status, a snapshot of both addresses, and an optional link to a customer.

ColumnTypeNotes
iduuidPrimary key
numbertextNOT NULL, UNIQUE, auto-generated
customer_iduuidFK to customers, ON DELETE SET NULL
statusorder_statusEnum, defaults to pending
subtotal_centsbigintLine items before tax, shipping, discount
tax_centsbigint
shipping_centsbigint
discount_centsbigint
total_centsbigintFinal amount
currencytextDefaults to 'USD'
payment_providertextSlug of the provider used, nullable
payment_reftextProvider-side reference, nullable
shipping_addressjsonbSnapshot, defaults to {}
billing_addressjsonbSnapshot, defaults to {}
notestextNullable
created_attimestamptzDefaults to now()

The number column has a default that calls generate_order_number(). That function reads the order_number_prefix key from the settings table, falls back to MR- when the key is absent, and appends a zero-padded counter from a sequence. The first order is MR-0001 unless you change the prefix setting.

order_items

Line items belong to an order through order_id with ON DELETE CASCADE: deleting an order deletes its items. Each item snapshots the SKU, name, and unit price at purchase time, so later edits to the product never rewrite history.

ColumnTypeNotes
order_iduuidFK to orders, cascade on delete
product_iduuidFK to products, ON DELETE RESTRICT
variant_iduuidFK to product_variants, restrict, nullable
sku_snapshottextSKU at purchase time
name_snapshottextNOT NULL
price_cents_snapshotbigintUnit price at purchase time
quantityintegerCHECK (quantity >= 0)
tax_centsbigint
discount_centsbigint

The ON DELETE RESTRICT on product_id means the database refuses to delete a product that any order item references. That protects order history from dangling links.

The order state machine

The order_status enum has five values: pending, paid, fulfilled, refunded, cancelled. Status changes do not happen with a plain UPDATE. They go through the transition_order_status(p_order_id uuid, p_to_status order_status, p_reason text) RPC, which the backoffice calls via supabase.rpc('transition_order_status',...). The RPC locks the order row FOR UPDATE, checks the move against a fixed table of legal transitions, applies it, and writes an audit row.

The legal moves are:

  • pending can go to paid or cancelled
  • paid can go to fulfilled, refunded, or cancelled
  • fulfilled can go to refunded
  • refunded is terminal
  • cancelled is terminal

Any other move fails. If you call the RPC with an illegal target the database raises illegal transition pending -> fulfilled (with your actual from and to values) under SQLSTATE check_violation. If the order id does not exist it raises order <id> not found under no_data_found. The RPC requires the editor role; a viewer hits forbidden: requires editor role.

// templates/backend/src/lib/orders/mutations.ts
const { data, error } = await supabase.rpc('transition_order_status', {
 p_order_id: orderId,
 p_to_status: 'paid',
 p_reason: 'stripe webhook',
});

The same transition table is mirrored in TypeScript at src/lib/orders/state-machine.ts through canTransition(from, to), nextLegalStatuses(from), and isTerminal(status), so the UI can grey out illegal buttons before it ever calls the database.

order_status_transitions

Every successful status change appends one row here: the order id, from_status, to_status, an optional reason, the acting user (actor_id), and occurred_at. This is the order audit trail. Any staff role can read it; rows are written only by the transition RPC, never directly.

coupons

One row per discount code. The code column is citext and UNIQUE, so codes match case-insensitively. The type column is the coupon_type enum with four values: percent, fixed, free_shipping, and bxgy (buy X get Y).

ColumnTypeNotes
codecitextNOT NULL, UNIQUE
typecoupon_typeDefaults to percent
valuenumericPercent points or fixed cents, per type
min_subtotal_centsbigintMinimum cart subtotal to qualify
max_usesintegerNullable; null means unlimited
used_countintegerDefaults to 0
applies_tojsonbProduct, category, collection scoping
starts_attimestamptzNullable window start
ends_attimestamptzNullable window end
is_activebooleanDefaults to true

The backoffice validates a coupon against a cart with the pure function canRedeem(coupon, cart, ctx) in src/lib/coupons/validate.ts. It returns either a computed discountCents or a structured failure reason: coupon_inactive, not_yet_started, expired, max_uses_reached, subtotal_below_minimum, or no_eligible_products. These mirror the checks of the order state machine in spirit: validate first, mutate second.

redeem_coupon is defined but not wired

The database ships a redeem_coupon RPC that atomically increments used_count and inserts a coupon_redemptions row under a row lock. It is not called from the shipped backoffice UI, which validates with canRedeem instead. Treat the RPC as available infrastructure for your generated storefront checkout, not as a button in the template today.

coupon_redemptions

One row per use of a coupon: coupon_id (cascade on delete), order_id (cascade on delete), an optional customer_id (ON DELETE SET NULL), and redeemed_at. This is the ledger behind a coupon's used_count.

Shipping

Shipping is two tables. A zone groups countries; a method is a rate inside a zone.

shipping_zones

ColumnTypeNotes
nametextNOT NULL
countriestext[]ISO country codes, defaults to empty array
regionsjsonbFiner scoping, defaults to {}

shipping_methods

Each method belongs to a zone (zone_id, cascade on delete) and has a rate type. The shipping_rate_type enum is flat, weight, or free_threshold.

ColumnTypeNotes
zone_iduuidFK to shipping_zones, cascade
nametextNOT NULL
typeshipping_rate_typeDefaults to flat
rate_centsbigintCharge in cents
free_threshold_centsbigintNullable; used by free_threshold type
transit_days_minintegerNullable
transit_days_maxintegerNullable

Payment providers and transactions

You choose which payment provider your store uses. MaShop never sits in the money path and never sees the funds. The backoffice stores one row per configured provider and one row per money movement.

payment_providers

One row per provider you enable, keyed by a unique slug. The provider's API keys live in credentials_encrypted as a pgcrypto-encrypted jsonb blob, never as plaintext columns.

ColumnTypeNotes
slugtextNOT NULL, UNIQUE
display_nametextNOT NULL
modetextCHECK (mode IN ('test', 'live'))
credentials_encryptedjsonbpgcrypto cipher, defaults to {}
is_enabledbooleanDefaults to false
positionintegerDisplay order

Credentials never travel through PostgREST as readable JSON. They are written and read only through two SECURITY DEFINER RPCs: set_payment_provider_credentials(p_slug, p_credentials) encrypts with pgp_sym_encrypt and stores the base64 cipher; get_payment_provider_credentials(p_slug) decrypts and returns the JSON. Both require the admin role, matching the owner/admin-only write policy on the table. The encryption key comes from the app.settings.encryption_key database setting.

Set the encryption key

If app.settings.encryption_key is unset, the RPCs fall back to a development sentinel string. Set a real 32-byte key on your Supabase project before storing live provider credentials, or your secrets are encrypted with a known default.

payment_transactions

One row per money movement against an order (order_id, cascade on delete). The type is the payment_tx_type enum (authorization, capture, refund, void) and the status is the payment_tx_status enum (pending, succeeded, failed, cancelled).

ColumnTypeNotes
order_iduuidFK to orders, cascade
provider_slugtextNOT NULL
typepayment_tx_typeDefaults to authorization
statuspayment_tx_statusDefaults to pending
amount_centsbigintGross amount
fee_centsbigintProvider fee; net = amount_cents - fee_cents
currencytextDefaults to 'USD'
provider_reftextProvider-side id, nullable
raw_responsejsonbFull provider payload, defaults to {}
created_attimestamptzDefaults to now()

Net revenue is always derived as amount_cents - fee_cents and never stored as its own column. Your payment webhook populates fee_cents from the provider's balance payload.

Who can read and write

All commerce tables enforce RLS through the staff role model. Reads require any staff role. Writes require owner, admin, or editor on most tables; payment_providers writes require owner or admin. The role check itself lives in require_staff_role(min_role), which raises forbidden: not staff when the caller has no role and forbidden: requires editor role (with the needed role name) when the caller is under-privileged. None of these tables grant the anonymous role any access, so your public storefront cannot read orders, customers, or payment rows. See Row Level Security for the full policy set and RPCs for every SECURITY DEFINER function.

commerceordersreference
Was this page helpful?
Your feedback is anonymous.