Skip to content
scsiwyg
sign insign up
get startedmcpcommunityapiplaygroundswaggersign insign up
Stone Maps

Buying a Stone: Stripe Integration and Fulfillment

#stonemaps#devlog#build#feature

Buying a Stone: Stripe Integration and Fulfillment

Stone Maps is not only software. The physical stones are real objects — smooth, hand-selected, each with a QR code that connects it to the digital layer. At some point, that means someone has to buy one.

The commerce layer is Stripe. Here's how it's built, what the order schema looks like, and the decision behind selling physical objects as the entry point to a journaling app.

The Product Catalog

Products live in their own table, separate from stones:

export const products = pgTable('products', {
  name: text('name').notNull(),
  type: text('type', { enum: ['VIRTUAL', 'PHYSICAL'] }).notNull(),
  stoneCount: integer('stone_count').notNull(),
  priceInCents: integer('price_in_cents').notNull(),
  stripePriceId: text('stripe_price_id'),
  stripeProductId: text('stripe_product_id'),
  isActive: boolean('is_active').default(true).notNull(),
  metadata: jsonb('metadata'),
});

Both VIRTUAL and PHYSICAL products exist. A virtual product creates pebbles (digital-only stones) — useful for bulk onboarding or gifting digital access. A physical product ships real stones.

stoneCount is how many stones are included. A single-stone product has stoneCount: 1. A "starter pack" could be 3. The order process creates that many stone records and associates them with the order.

stripePriceId and stripeProductId are the Stripe-side identifiers. The product is created in Stripe's dashboard first, then referenced here. This is the standard pattern: Stripe owns pricing, we own the association between Stripe prices and our product records.

The Order Schema

An order records everything about a purchase:

export const orders = pgTable('orders', {
  userId: uuid('user_id').references(() => users.id).notNull(),
  productId: uuid('product_id').references(() => products.id).notNull(),
  status: text('status', { enum: ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'REFUNDED'] }).notNull(),
  totalAmountInCents: integer('total_amount_in_cents').notNull(),
  stripeCheckoutSessionId: text('stripe_checkout_session_id').unique(),
  stripePaymentIntentId: text('stripe_payment_intent_id'),
  shippingAddress: jsonb('shipping_address').$type<{
    name?: string; line1?: string; line2?: string; city?: string;
    state?: string; postal_code?: string; country?: string;
  }>(),
  trackingNumber: text('tracking_number'),
  shippedAt: timestamp('shipped_at'),
  stoneIds: jsonb('stone_ids').$type<string[]>(),
  isPremapped: boolean('is_premapped').default(false).notNull(),
  premappedLocationIds: jsonb('premapped_location_ids').$type<string[]>(),
});

stoneIds is set when the order is fulfilled — the specific stone records assigned to this order. Before fulfillment, the order exists but no stones are allocated yet. The allocation happens when you actually pick up a physical stone and attach a QR code to it.

isPremapped and premappedLocationIds cover the special case of a premapped order — stones that ship already associated with a specific location. A museum might order a batch of premapped stones, each one pre-assigned to a specific gallery or exhibit.

shippingAddress is a JSONB blob using Stripe's address field names. We store Stripe's format directly rather than transforming it to our own schema, which means less code to write and no risk of mapping errors.

The Checkout Flow

When a user initiates a purchase, we create a Stripe Checkout Session:

  1. User selects a product and quantity
  2. API creates a Stripe Checkout Session with the stripePriceId, shipping address collection enabled, and a metadata field containing the user's ID and our productId
  3. User is redirected to Stripe's hosted checkout page
  4. Stripe handles card entry, 3DS, address validation — all the hard parts
  5. On success, Stripe redirects back to our success URL

The order record is created at step 2 in PENDING status with the stripeCheckoutSessionId. This gives us a record of the intent even if the user abandons the checkout.

Fulfillment via Webhook

Stripe webhooks handle the rest. When a payment succeeds, Stripe sends a payment_intent.succeeded event. Our webhook endpoint:

  1. Verifies the event signature (preventing spoofed webhooks)
  2. Looks up the order by stripePaymentIntentId
  3. Updates order status to PROCESSING
  4. Creates the stone records (stoneCount new rows in the stones table)
  5. Associates the stones with the order via stoneIds
  6. Updates order status to COMPLETED

For physical orders, the fulfillment flow then diverges to manual process: the order appears in the admin orders panel, someone prints the QR code for the allocated stones, attaches them to physical stones, and ships them with the shipping address from the order record. The trackingNumber and shippedAt are updated manually through the admin interface when the package goes out.

This is not automated fulfillment. It's a spreadsheet with a database. For early access numbers — single digits to low tens of orders — it works. Real automated fulfillment (integration with a fulfillment warehouse, automatic label generation) is a future problem.

The Payment Record

Payments are tracked separately from orders:

export const payments = pgTable('payments', {
  orderId: uuid('order_id').references(() => orders.id).notNull(),
  stripePaymentIntentId: text('stripe_payment_intent_id').unique(),
  amount: integer('amount').notNull(),
  currency: text('currency').default('usd').notNull(),
  status: text('status', { enum: ['PENDING', 'SUCCEEDED', 'FAILED', 'REFUNDED'] }).notNull(),
  stripeInvoiceId: text('stripe_invoice_id'),
  metadata: jsonb('metadata'),
});

The separation between orders and payments is deliberate. An order might eventually have multiple payments (split payments, partial refunds, recharges). A payment might fail and be retried. The one-to-many relationship between orders and payments is cleaner than embedding payment state in the order record.

In practice, for now, every order has exactly one payment. But the schema is ready for the more complex cases.

Why Sell Stones at All

This deserves a direct answer, because it's a real design question. Why is the entry point to Stone Maps a physical purchase?

The answer is intentionality. The decision to spend money on a small smooth stone with a QR code on it is not a casual decision. It means something. You've thought about it. You've committed to starting a practice of noticing.

A free app with optional physical upgrade would have a different character. The people who just try it and leave would outnumber the people who mean it. Stone Maps is not trying to maximize users. It's trying to serve the people who actually want what it offers.

The pebble path exists for people who want to try before committing — or who can't buy a stone for practical reasons. But the physical stone is the intended beginning. You hold a thing. You scan it. You give it a name. That ritual is part of the design.