[TERMINAL · SKILLS]
> mounting /skills...
> indexing 295 manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Terminal.skills
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.

#redis#caching#database#pub-sub#queues
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 93/100·All SAFE
>

typescript

v

Not yet scored
View skill
>

redis

v1.0.0

Build applications with Redis — caching, session storage, pub/sub, streams, rate limiting, leaderboards, and queues. Use when tasks involve in-memory data storage, real-time messaging, distributed locking, or performance optimization with caching layers.

93/100 quality
1.81× impact
SAFE
View skill
>

postgresql

v1.0.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.

87/100 quality
1.53× impact
SAFE
View skill
>

hono

v1.0.0

You are an expert in Hono, the ultrafast web framework for the edge. You help developers build APIs and web applications that run on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda, and Vercel Edge — with a tiny footprint (~14KB), middleware ecosystem, JSX support, RPC client, and Web Standards API compatibility that makes code truly portable across runtimes.

93/100 quality
3.00× impact
SAFE
View skill
>

zod

v1.0.0

You are an expert in Zod, the TypeScript-first schema declaration and validation library. You help developers define schemas that validate data at runtime AND infer TypeScript types at compile time — eliminating the need to write types and validators separately. Used for API input validation, form validation, environment variables, config files, and any data boundary.

100/100 quality
1.21× impact
SAFE
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%