Marketplace Starter Kit

Payments

Stripe integration for booking payments, setup intents, and secure card storage.

Overview

The payment system in this marketplace app is built using Stripe to handle secure, PCI-compliant payments. It integrates deeply with your booking lifecycle and user management. The system includes:

  • Customer management via Stripe
  • Card saving via SetupIntent
  • Payment collection via PaymentIntent
  • Booking-to-payment binding in your database
  • Safe card detail retrieval using Stripe's PaymentMethod API

1. Payment Model (Prisma)

The Prisma model Payment stores all transaction metadata.

model Payment {
  id         String   @id @default(cuid())
  amount     Float
  currency   String   @default("usd")
  userId     String
  user       User     @relation(fields: [userId], references: [id])
  bookingId  String
  booking    Booking  @relation(fields: [bookingId], references: [id])
  cardId     String?  // Stripe PaymentMethod ID
  status     PaymentStatus
  createdAt  DateTime @default(now())
}
  • Tied to both User and Booking
  • cardId stores the Stripe PaymentMethod ID used for the charge
  • status reflects real-time state (e.g. PENDING, SUCCEEDED, FAILED)

2. Stripe SetupIntent – Saving Cards

In stripe.ts router:

createSetupIntent: protectedProcedure.mutation(async ({ ctx }) => {
  let user = ctx.user;

  if (!user.stripeCustomerId) {
    const customer = await stripe.customers.create({
      email: user.email,
      name: user.name || undefined,
      metadata: { userId: user.id },
    });

    user = await ctx.db.user.update({
      where: { id: ctx.user.id },
      data: { stripeCustomerId: customer.id },
    });
  }

  const intent = await stripe.setupIntents.create({
    customer: user.stripeCustomerId,
  });

  return { clientSecret: intent.client_secret };
});

Frontend: Use this clientSecret with Stripe Elements to securely collect card details.


3. getByBooking – Fetching Payment Details

Located in payment.ts router:

getByBooking: protectedProcedure
  .input(z.object({ bookingId: z.string() }))
  .query(async ({ ctx, input }) => {
    const payments = await ctx.db.payment.findMany({
      where: { bookingId: input.bookingId },
      include: { user: true },
      orderBy: { createdAt: "desc" },
    });

    const enriched = await Promise.all(
      payments.map(async (payment) => {
        const method = await stripe.paymentMethods.retrieve(payment.cardId!);
        return { ...payment, paymentMethod: method };
      })
    );

    return enriched;
  });

This:

  • Queries all payments for a booking
  • Enriches each with Stripe card info using the stored cardId

4. Payment Lifecycle

Step-by-step flow:

  1. User saves card (SetupIntent)
  2. User creates a booking
  3. On host acceptance, the backend triggers stripe.paymentIntents.create(...)
  4. When payment is successful:
    • Status is updated to SUCCEEDED
    • Booking is confirmed
  5. Stripe webhook (recommended) should catch fallback events (e.g. failure, refund)

5. Stripe Objects In Use

Stripe ObjectPurpose
CustomerOne per user (id saved in DB)
SetupIntentSave new card
PaymentMethodStores card ID
PaymentIntentCharge on booking confirmation

6. Webhook Handling (To be implemented)

Best practice is to handle events like:

  • payment_intent.succeeded
  • payment_intent.failed
  • setup_intent.succeeded

You would create a POST /api/webhooks/stripe route and verify each event.


7. Recommendations

  • Enforce unique Stripe Customer per user
  • Require saved card before allowing bookings
  • Use Stripe Elements for PCI compliance
  • Add retry logic on failed payments
  • Track PaymentStatus transitions in audit log