Skip to content
Return to Projects
Case Study

Payday Chat

A real-time, members-only network for online business founders.

The Problem

Founders needed a private, high-signal place to network, build teams, and scale revenue — not another noisy public feed. Payday Chat is invite-only and built around real-time collaboration.

My Role

Built solo with AI-assisted development — Flutter client, NestJS API, data model, real-time layer, and deployment.

Highlights

  • Real-time messaging with presence and live events over WebSockets
  • One NestJS API powering both a Flutter mobile app and a web client
  • PostgreSQL domain model with Redis for caching and pub/sub fan-out
  • NGINX gateway, Stripe payments, and OAuth authentication

Stack

FlutterDartNestJSPostgreSQLRedisNGINXStripe

Constraints

  • Invite-only product — auth and access control had to be correct from day one.
  • One backend, two clients (Flutter + web) — schema and contracts had to be shared, not duplicated.
  • Realtime is a feature, not a nice-to-have — presence and live events must survive reconnects.

System Architecture

Clients
Flutter Mobile App
Web App
Gateway
NGINX
API
NestJS REST
WebSocket Gateway
Data
PostgreSQL
Redis (cache + pub/sub)
External
Stripe
OAuth

Key Trade-offs

The decisions worth defending — what I chose, what I turned down, and why.

Realtime transport

Chose

WebSockets with Redis pub/sub fan-out

Rejected

Long-polling or third-party realtime SaaS

Predictable latency, no per-message vendor cost, and Redis already in the stack for caching — one fewer moving part.

Mobile client framework

Chose

Flutter (single codebase for iOS + Android)

Rejected

Native Swift + Kotlin clients

Solo build — two native clients would have doubled the surface area and slowed iteration on the API.

API style

Chose

REST + dedicated WebSocket gateway

Rejected

GraphQL subscriptions

Simpler operational story, easier to cache at the NGINX layer, and the realtime channel stays an explicit, observable component.

What I'd Do Differently

An honest retrospective — the stuff I'd change with more time, more users, or a second pass.

  1. 1Introduce contract tests between the NestJS API and the Flutter client earlier — a few breakages were caught only at runtime.
  2. 2Move long-lived sockets to a dedicated process so API deploys don't drop client connections.
  3. 3Add structured event versioning from day one instead of retrofitting it once the schema started moving.

Technical Deep-Dive

Architecture, specifications, and implementation details.

Payments Module — Stripe Spec

#Overview

Stripe handles all subscription billing. The NestJS payments module:

  • Creates Stripe customers on first payment interaction
  • Manages subscription checkout and upgrades
  • Processes Stripe webhook events to keep local state in sync
  • Provides billing portal URL for self-serve management

#Module Files

src/payments/
├── payments.module.ts
├── payments.controller.ts
├── payments.service.ts
├── payments.webhook.controller.ts   # Separate controller — no global JWT guard
├── entities/
│   └── subscription.entity.ts
└── dto/
    └── create-checkout.dto.ts

#Stripe Products Setup (One-Time)

Before running the app, create these products and prices in the Stripe Dashboard:

Product: Payday Starter
  Price: $29/month recurring → copy Price ID to STRIPE_STARTER_PRICE_ID

Product: Payday Pro
  Price: $79/month recurring → copy Price ID to STRIPE_PRO_PRICE_ID

Product: Payday Elite
  Price: $197/month recurring → copy Price ID to STRIPE_ELITE_PRICE_ID

#Checkout Flow

1. User clicks "Upgrade to Pro" on frontend
2. Frontend calls: POST /payments/checkout { tier: 'pro' }
3. PaymentsService:
   a. Find or create Stripe Customer (stores stripe_customer_id in subscriptions)
   b. Create Checkout Session:
      - mode: 'subscription'
      - price: STRIPE_PRO_PRICE_ID
      - success_url: {FRONTEND_URL}/dashboard?upgraded=true
      - cancel_url: {FRONTEND_URL}/pricing
4. Return { checkoutUrl } to frontend
5. Frontend redirects to checkoutUrl (Stripe-hosted checkout page)
6. User completes payment on Stripe
7. Stripe sends webhook: customer.subscription.created
8. Webhook handler updates: users.membership_tier = 'pro'

#Webhook Handler

// payments.webhook.controller.ts
@Controller('payments')
export class PaymentsWebhookController {
  
  @Post('webhook')
  @Public()   // No JWT — Stripe calls this directly
  @HttpCode(200)
  async handleWebhook(
    @Headers('stripe-signature') signature: string,
    @Req() req: RawBodyRequest<Request>,
  ) {
    let event: Stripe.Event;

    try {
      event = this.stripe.webhooks.constructEvent(
        req.rawBody,   // IMPORTANT: must be raw bytes, not parsed JSON
        signature,
        this.config.get('stripe.webhookSecret'),
      );
    } catch {
      throw new BadRequestException('Invalid webhook signature');
    }

    switch (event.type) {
      case 'customer.subscription.created':
      case 'customer.subscription.updated':
        await this.paymentsService.handleSubscriptionChange(event.data.object);
        break;

      case 'customer.subscription.deleted':
        await this.paymentsService.handleSubscriptionCancelled(event.data.object);
        break;

      case 'invoice.payment_failed':
        await this.paymentsService.handlePaymentFailed(event.data.object);
        break;
    }

    return { received: true };
  }
}

Critical: The webhook endpoint needs the raw request body (not parsed JSON). In main.ts, configure Express to expose raw body:

const app = await NestFactory.create(AppModule, {
  rawBody: true,   // Enables req.rawBody for webhook verification
});

#Subscription State Machine

null (no subscription)
  → trialing       (if free trial configured)
  → active         (payment successful)
  → past_due       (payment failed, Stripe retrying)
  → cancelled      (user cancelled or max retries exceeded)
  → incomplete     (first payment failed)

When status = 'past_due':
  - Keep membership tier active (grace period)
  - Send payment failure email
  - Show banner on frontend

When status = 'cancelled':
  - Downgrade users.membership_tier to 'starter'
  - Send cancellation email

#Tier Mapping

function stripePriceToTier(priceId: string): MembershipTier {
  const map = {
    [process.env.STRIPE_STARTER_PRICE_ID]: 'starter',
    [process.env.STRIPE_PRO_PRICE_ID]: 'pro',
    [process.env.STRIPE_ELITE_PRICE_ID]: 'elite',
  };
  return map[priceId] ?? 'starter';
}

#handleSubscriptionChange (Core Logic)

async handleSubscriptionChange(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string;
  const priceId = subscription.items.data[0].price.id;
  const tier = stripePriceToTier(priceId);

  // Find user by stripe_customer_id
  const sub = await this.subscriptionRepo.findOne({
    where: { stripeCustomerId: customerId },
  });
  if (!sub) return;

  // Update subscription record
  await this.subscriptionRepo.update(sub.id, {
    stripeSubscriptionId: subscription.id,
    stripePriceId: priceId,
    plan: tier,
    status: subscription.status as any,
    currentPeriodStart: new Date(subscription.current_period_start * 1000),
    currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    cancelAtPeriodEnd: subscription.cancel_at_period_end,
  });

  // Update user's membership tier
  await this.userRepo.update(sub.userId, { membershipTier: tier });

  // Notify user of tier change
  await this.notificationsService.notify({
    recipientId: sub.userId,
    type: 'membership_update',
    title: `Your membership has been updated to ${tier}`,
    body: `You now have access to all ${tier} features.`,
    actionUrl: '/dashboard',
  });
}

#Billing Portal

async getBillingPortalUrl(userId: string): Promise<string> {
  const sub = await this.subscriptionRepo.findOne({ where: { userId } });
  if (!sub?.stripeCustomerId) {
    throw new BadRequestException('No billing account found');
  }

  const session = await this.stripe.billingPortal.sessions.create({
    customer: sub.stripeCustomerId,
    return_url: `${this.config.get('frontendUrl')}/dashboard`,
  });

  return session.url;
}

#Testing Stripe Locally

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to local dev server
stripe listen --forward-to localhost/api/v1/payments/webhook

# Test specific events
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed

# Test card numbers (in Stripe test mode)
# Success: 4242 4242 4242 4242 (any future date, any CVC)
# Decline: 4000 0000 0000 0002
# Requires auth: 4000 0027 6000 3184
~ End of Document ~