Replit prompt — Add creator payouts (Stripe Connect)
Goal

When a cart includes creator-owned assets, pay creators automatically after checkout.session.completed. Platform keeps PLATFORM_FEE_BPS.

1) Tag each cart item with its ownerId

Where you build cart items (e.g. stock/mockup card → addItem({...})), include ownerId:

addItem({
  kind: "stock",            // or "mockup" / your types
  assetId: item.id,
  ownerId: item.ownerId || "platform", // <— include the creator’s userId
  name: item.name,
  priceCents: 699,
  qty: 1,
  previewUrl: item.url
});

2) Pass a compact owner map into the Checkout Session

routes/checkoutRoutes.js – when creating the session, include a compact CSV in metadata:

// Build line_items as you already do...
const ownerMap = []; // ["assetId:ownerId:amountCents", ...]
for (const item of items) {
  if (item.kind === "stock" /* or your types */) {
    const unit = Number(item.priceCents || 699);
    const qty  = Number(item.qty || 1);
    const total = unit * qty;
    ownerMap.push(`${item.assetId}:${item.ownerId || 'platform'}:${total}`);
    // existing line_items.push({ price_data: ..., quantity: qty })
  }
}

const session = await stripe.checkout.sessions.create({
  // ...existing options...
  metadata: {
    userId: String(req.user?.id || "anon"),
    assetIds: purchasedAssetIds.join(","),
    ownerMap: ownerMap.join("|") // e.g. "a1:u_123:699|a2:u_555:1299"
  },
});


This keeps metadata small; fine for typical carts. If you expect very large carts, store a temporary record in your DB and save an ID in metadata instead.

3) On webhook, compute creator shares and send Transfers

routes/stripeWebhook.js – handle checkout.session.completed:

const { getUser } = require("../services/users"); // must return { creator: { connectId } } for creators

function calcSplit(amountCents) {
  const bps = Number(process.env.PLATFORM_FEE_BPS || 3000);
  const fee  = Math.floor((amountCents * bps) / 10000);
  const toCreator = amountCents - fee;
  return { fee, toCreator };
}

async function payCreatorsForSession(session, stripe) {
  const map = (session.metadata?.ownerMap || "").split("|").filter(Boolean);
  if (!map.length) return;

  // Aggregate per ownerId
  const perOwner = new Map(); // ownerId -> cents
  for (const row of map) {
    const [assetId, ownerId, amtStr] = row.split(":");
    const amt = Number(amtStr || 0);
    if (!ownerId || !amt) continue;
    perOwner.set(ownerId, (perOwner.get(ownerId) || 0) + amt);
  }

  // Create transfers for each creator (skip "platform")
  for (const [ownerId, gross] of perOwner.entries()) {
    if (ownerId === "platform") continue;
    const user = getUser(ownerId);
    const connectId = user?.creator?.connectId;
    if (!connectId) continue; // not onboarded: skip or queue

    const { toCreator } = calcSplit(gross);

    // Transfer from platform balance to the creator’s account
    await stripe.transfers.create({
      amount: toCreator,
      currency: session.currency || "usd",
      destination: connectId,
      metadata: {
        sessionId: session.id,
        ownerId,
      },
    });
  }
}

router.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.headers["stripe-signature"];
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  (async () => {
    switch (event.type) {
      case "checkout.session.completed": {
        const session = event.data.object;
        await payCreatorsForSession(session, stripe);
        // TODO: grant licenses to buyer for session.metadata.assetIds
        break;
      }
      // ...existing cases...
    }
  })()
  .then(() => res.json({ received: true }))
  .catch((e) => {
    console.error("Webhook handler error", e);
    res.status(500).send("Webhook handler failed");
  });
});


Notes

This uses the separate transfer model: funds land on the platform, then you transfer the creator’s share. Simple and reliable for Checkout.

If you prefer “destination charges” (routing money at payment time), we can switch to payment_intent_data.transfer_data.destination + application_fee_amount on the Session. The above is easier to ship first.

4) Enforce min/max price

Frontend: block form submit if price < CREATOR_MIN_PRICE_CENTS or > CREATOR_MAX_PRICE_CENTS.

Backend (creator asset save/update): reject out-of-range prices with 400.

5) Gate paid listings by onboarding

In Creator Dashboard, show “Start Stripe Onboarding” if no connectId.

In upload/save API: if listing is paid and creator has no connectId, return 400 with message “Finish payouts setup.”