[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build a Payment Link Generator

Build a Payment Link Generator

Build a payment link system with customizable checkout pages, expiring links, partial payments, multi-currency support, branded receipts, and conversion analytics.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 96/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

postgresql

v1.1.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

100/100 quality
5.15× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
$

The Problem

Marta runs a 15-person agency. Invoicing is manual: create PDF, email it, hope the client pays. 40% of invoices are paid late. Clients ask "can I pay by card?" but the agency only accepts wire transfers. They tried Stripe Payment Links but can't customize the page, track which links convert, set expiration dates, or allow partial payments for large projects. They need branded payment links they can send via email or embed in proposals — with tracking, expiry, and flexible payment options.

Step 1: Build the Payment Link Engine

typescript
// src/payments/links.ts — Payment links with branding, expiry, partial payments, and analytics
import { pool } from "../db";
import { Redis } from "ioredis";
import Stripe from "stripe";
import { createHash, randomBytes } from "node:crypto";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const redis = new Redis(process.env.REDIS_URL!);

interface PaymentLink {
  id: string;
  shortCode: string;
  url: string;
  title: string;
  description: string;
  amount: number;              // cents
  currency: string;
  status: "active" | "paid" | "expired" | "cancelled";
  settings: {
    expiresAt: string | null;
    allowPartial: boolean;
    minPartialAmount: number;
    maxUses: number;           // 0 = single use
    collectAddress: boolean;
    collectPhone: boolean;
    customFields: Array<{ label: string; required: boolean }>;
    successUrl: string | null;
    successMessage: string;
    metadata: Record<string, string>;
  };
  branding: {
    companyName: string;
    logoUrl: string | null;
    accentColor: string;
    footerText: string;
  };
  payments: PaymentRecord[];
  totalPaid: number;
  remainingAmount: number;
  createdBy: string;
  createdAt: string;
}

interface PaymentRecord {
  id: string;
  amount: number;
  stripePaymentId: string;
  customerEmail: string;
  customerName: string;
  paidAt: string;
}

// Create payment link
export async function createPaymentLink(params: {
  title: string;
  description?: string;
  amount: number;
  currency?: string;
  expiresIn?: number;          // hours
  allowPartial?: boolean;
  minPartialAmount?: number;
  maxUses?: number;
  collectAddress?: boolean;
  customFields?: Array<{ label: string; required: boolean }>;
  successMessage?: string;
  branding?: Partial<PaymentLink["branding"]>;
  metadata?: Record<string, string>;
  createdBy: string;
}): Promise<PaymentLink> {
  const id = `pl-${Date.now().toString(36)}${randomBytes(4).toString("hex")}`;
  const shortCode = randomBytes(6).toString("base64url");

  const link: PaymentLink = {
    id,
    shortCode,
    url: `${process.env.APP_URL}/pay/${shortCode}`,
    title: params.title,
    description: params.description || "",
    amount: params.amount,
    currency: params.currency || "USD",
    status: "active",
    settings: {
      expiresAt: params.expiresIn ? new Date(Date.now() + params.expiresIn * 3600000).toISOString() : null,
      allowPartial: params.allowPartial || false,
      minPartialAmount: params.minPartialAmount || 500, // $5 minimum
      maxUses: params.maxUses || 1,
      collectAddress: params.collectAddress || false,
      collectPhone: false,
      customFields: params.customFields || [],
      successUrl: null,
      successMessage: params.successMessage || "Payment received. Thank you!",
      metadata: params.metadata || {},
    },
    branding: {
      companyName: params.branding?.companyName || process.env.COMPANY_NAME || "",
      logoUrl: params.branding?.logoUrl || null,
      accentColor: params.branding?.accentColor || "#228BE6",
      footerText: params.branding?.footerText || "",
    },
    payments: [],
    totalPaid: 0,
    remainingAmount: params.amount,
    createdBy: params.createdBy,
    createdAt: new Date().toISOString(),
  };

  await pool.query(
    `INSERT INTO payment_links (id, short_code, title, description, amount, currency, status, settings, branding, created_by, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8, $9, NOW())`,
    [id, shortCode, link.title, link.description, link.amount, link.currency,
     JSON.stringify(link.settings), JSON.stringify(link.branding), params.createdBy]
  );

  // Cache for fast lookup
  await redis.setex(`paylink:${shortCode}`, 86400 * 90, JSON.stringify(link));

  // Track view analytics
  await redis.hset(`paylink:stats:${id}`, { created: Date.now(), views: 0, payments: 0 });

  return link;
}

// Get payment link by short code (checkout page)
export async function getPaymentLink(shortCode: string, trackView: boolean = true): Promise<PaymentLink | null> {
  const cached = await redis.get(`paylink:${shortCode}`);
  let link: PaymentLink;

  if (cached) {
    link = JSON.parse(cached);
  } else {
    const { rows: [row] } = await pool.query("SELECT * FROM payment_links WHERE short_code = $1", [shortCode]);
    if (!row) return null;
    link = parsePaymentLink(row);
  }

  // Check expiry
  if (link.settings.expiresAt && new Date(link.settings.expiresAt) < new Date()) {
    if (link.status === "active") {
      link.status = "expired";
      await pool.query("UPDATE payment_links SET status = 'expired' WHERE id = $1", [link.id]);
      await redis.setex(`paylink:${shortCode}`, 86400, JSON.stringify(link));
    }
  }

  if (trackView) {
    await redis.hincrby(`paylink:stats:${link.id}`, "views", 1);
  }

  return link;
}

// Process payment for a link
export async function processPayment(
  shortCode: string,
  paymentMethodId: string,
  amount: number,
  customerInfo: { email: string; name: string; address?: any }
): Promise<{ success: boolean; error?: string; receiptUrl?: string }> {
  const link = await getPaymentLink(shortCode, false);
  if (!link) return { success: false, error: "Payment link not found" };
  if (link.status !== "active") return { success: false, error: `Payment link is ${link.status}` };

  // Validate amount
  const payableAmount = link.settings.allowPartial ? amount : link.remainingAmount;
  if (link.settings.allowPartial) {
    if (amount < link.settings.minPartialAmount) {
      return { success: false, error: `Minimum payment is $${(link.settings.minPartialAmount / 100).toFixed(2)}` };
    }
    if (amount > link.remainingAmount) {
      return { success: false, error: `Maximum payment is $${(link.remainingAmount / 100).toFixed(2)}` };
    }
  }

  // Create Stripe payment
  try {
    const paymentIntent = await stripe.paymentIntents.create({
      amount: payableAmount,
      currency: link.currency.toLowerCase(),
      payment_method: paymentMethodId,
      confirm: true,
      receipt_email: customerInfo.email,
      description: link.title,
      metadata: { paymentLinkId: link.id, ...link.settings.metadata },
      automatic_payment_methods: { enabled: true, allow_redirects: "never" },
    });

    if (paymentIntent.status !== "succeeded") {
      return { success: false, error: "Payment failed. Please try again." };
    }

    // Record payment
    const payment: PaymentRecord = {
      id: paymentIntent.id,
      amount: payableAmount,
      stripePaymentId: paymentIntent.id,
      customerEmail: customerInfo.email,
      customerName: customerInfo.name,
      paidAt: new Date().toISOString(),
    };

    link.payments.push(payment);
    link.totalPaid += payableAmount;
    link.remainingAmount = link.amount - link.totalPaid;

    // Check if fully paid
    if (link.remainingAmount <= 0) {
      link.status = "paid";
    }

    // Check usage limit
    if (link.settings.maxUses > 0 && link.payments.length >= link.settings.maxUses) {
      link.status = "paid";
    }

    await pool.query(
      `UPDATE payment_links SET status = $2, total_paid = $3, remaining_amount = $4 WHERE id = $1`,
      [link.id, link.status, link.totalPaid, link.remainingAmount]
    );

    await pool.query(
      `INSERT INTO payment_link_payments (link_id, stripe_payment_id, amount, customer_email, customer_name, paid_at)
       VALUES ($1, $2, $3, $4, $5, NOW())`,
      [link.id, paymentIntent.id, payableAmount, customerInfo.email, customerInfo.name]
    );

    await redis.setex(`paylink:${shortCode}`, 86400 * 90, JSON.stringify(link));
    await redis.hincrby(`paylink:stats:${link.id}`, "payments", 1);

    return { success: true, receiptUrl: paymentIntent.charges?.data?.[0]?.receipt_url || undefined };
  } catch (err: any) {
    return { success: false, error: err.message };
  }
}

// Analytics
export async function getLinkAnalytics(linkId: string): Promise<{
  views: number; payments: number; conversionRate: number;
  totalCollected: number; averagePayment: number;
}> {
  const stats = await redis.hgetall(`paylink:stats:${linkId}`);
  const views = parseInt(stats.views || "0");
  const payments = parseInt(stats.payments || "0");

  const { rows: [totals] } = await pool.query(
    "SELECT COALESCE(SUM(amount), 0) as total, COALESCE(AVG(amount), 0) as avg FROM payment_link_payments WHERE link_id = $1",
    [linkId]
  );

  return {
    views, payments,
    conversionRate: views > 0 ? (payments / views) * 100 : 0,
    totalCollected: parseInt(totals.total),
    averagePayment: Math.round(parseFloat(totals.avg)),
  };
}

function parsePaymentLink(row: any): PaymentLink {
  return { ...row, settings: JSON.parse(row.settings), branding: JSON.parse(row.branding), payments: [], totalPaid: row.total_paid || 0, remainingAmount: row.remaining_amount || row.amount };
}

Results

  • Late payments: 40% → 12% — clients pay by card in 2 clicks from the email; no bank transfers, no "I'll do it later"
  • Partial payments for large projects — $50K project split into 3 milestones; client pays each link as work is delivered; no manual invoice tracking
  • Branded checkout — agency logo and colors on the payment page; feels professional, not like a generic Stripe form
  • Link expiry prevents stale invoices — 7-day expiry on quotes; creates urgency; expired links show "this offer has ended"
  • Conversion tracking — "This link was viewed 15 times but never paid" tells the team to follow up; overall view-to-pay conversion is 68%

Related use cases