Stripe Payments
Overview
Section titled “Overview”PinTeach uses Stripe Connect (Standard accounts). Each teacher connects their own Stripe account. Money goes directly to the teacher — PinTeach never holds funds.
Setup Flow
Section titled “Setup Flow”- Teacher clicks “Connect Stripe” in settings
- Redirect to Stripe OAuth consent
- Stripe redirects back with auth code
- Backend stores
stripeAccountIdon teacher record - Teacher can now accept payments
Payment Flows
Section titled “Payment Flows”Trial Session (Paid)
Section titled “Trial Session (Paid)”1. Student selects paid trial2. Create Session(hold) + holdExpiresAt = now + 15min3. Create Stripe Checkout Session (on teacher's connected account)4. Student completes payment5. Webhook: checkout.session.completed → Session(scheduled)Service Enrollment
Section titled “Service Enrollment”1. Student selects service2. Magic link auth (if new)3. Accept legal documents4. Redirect to Stripe Checkout5. Webhook: checkout.session.completed → Enrollment(active), credit GRANTSubscription Renewal
Section titled “Subscription Renewal”Webhook: invoice.paid → Auto-renew subscription enrollmentWebhook: customer.subscription.deleted → Cancel enrollmentWebhook Events
Section titled “Webhook Events”Endpoint: POST /api/webhooks/stripe
Signature verified via stripe-signature header.
| Event | Action |
|---|---|
checkout.session.completed | Activate enrollment / confirm payment |
invoice.paid | Auto-renew subscription |
customer.subscription.deleted | Cancel enrollment |
charge.refunded | Apply refund |
charge.dispute.created | Record dispute |
Idempotency
Section titled “Idempotency”All webhook processing is idempotent via webhook_events.stripeEventId:
const [inserted] = await db.insert(webhookEvents).values({ stripeEventId: event.id, type: event.type,}).onConflictDoNothing().returning();
if (!inserted) return; // Already processedCircuit Breaker
Section titled “Circuit Breaker”Stripe API calls are protected by a circuit breaker:
- Config: 3 failures in 30s → circuit opens
- Recovery: 15s half-open period
- Fallback: throws
SERVICE_UNAVAILABLE(503)
Only infrastructure errors (429, 5xx, timeouts) trip the circuit — not business errors.
Discount Codes
Section titled “Discount Codes”Discount codes are server-side, NOT Stripe Coupons. PinTeach adjusts the price before creating the Stripe Checkout Session.
Storage
Section titled “Storage”discount_codestable with soft-delete (deletedAt)discount_code_usestracks redemptions per student per enrollmentenrollments.discountCodeId+discountAmountrecord the applied discount
Discount Types
Section titled “Discount Types”| Type | Value Range | Description |
|---|---|---|
percentage | 0–100 | Percentage off the service price |
fixed_amount | cents | Fixed amount off (e.g., 500 = $5.00) |
Optional fields: currency (for fixed amounts), applicableServiceIds (empty array = all services).
Validation
Section titled “Validation”DiscountCodeService.validate() checks:
- Code exists, is active, and not soft-deleted
- Date range (
validFrom/validUntil) - Global usage limit (
maxUsesvsusesCount) - Per-student usage limit (
maxUsesPerStudent) - Applicable services (
applicableServiceIds)
1. Student enters code at checkout2. POST /:slug/validate-discount (rate-limited 10/min)3. Frontend shows adjusted price4. On enrollment: server re-validates + adjusts unit_amount5. Stripe Checkout Session created with discounted price6. Webhook confirms → discount_code_uses recordedPublic Endpoint
Section titled “Public Endpoint”POST /api/public/:slug/validate-discount — rate-limited to 10 requests per minute. Returns discount details or validation error.
Teacher Payments Dashboard
Section titled “Teacher Payments Dashboard”/teacher/payments provides:
- Payment history (filterable, paginated)
- Revenue KPIs
- CSV export
- Refund initiation