AkurAI Build
Menu

Public repository / 54864d769f47e4b584afb75e6ecc5b19890c21b4

AkurAI-Platform / payment/service.ts

Latest change 54864d769f47e4b584afb75e6ecc5b19890c21b4 - Add modular subscriptions with myPOS checkout by Ólafur Búi Ólafsson

import { Database } from "bun:sqlite";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
import { MyPOSClient, type PaymentFormFields, type WebhookValidationResult } from "mypos-online-checkout";
import embeddedCatalog from "../backend/subscriptions.json";
import {
  maskEmail,
  money,
  nextBillingDate,
  quoteCheckout,
  validateCatalog,
  type BillingInterval,
  type Catalog,
  type CheckoutQuote,
  type Product,
} from "./core";

type OrderStatus = "pending" | "paid" | "failed" | "canceled" | "rolled_back";
type SubscriptionStatus = "active" | "past_due" | "unpaid" | "canceled" | "requires_payment_method";

interface OrderRow {
  id: string;
  kind: "initial" | "renewal";
  subscription_id: string | null;
  customer_name: string;
  customer_email: string;
  company: string | null;
  interval: BillingInterval;
  products_json: string;
  unit_cents: number;
  amount_cents: number;
  currency: "EUR";
  status: OrderStatus;
  provider: string;
  provider_ref: string | null;
  provider_payload: string | null;
  manage_token_hash: string;
  period_start: string | null;
  error_code: string | null;
  created_at: string;
  updated_at: string;
  paid_at: string | null;
}

interface SubscriptionRow {
  id: string;
  initial_order_id: string;
  customer_email: string;
  interval: BillingInterval;
  products_json: string;
  unit_cents: number;
  amount_cents: number;
  currency: "EUR";
  status: SubscriptionStatus;
  card_token_enc: string | null;
  manage_token_hash: string;
  next_charge_at: string;
  retry_at: string | null;
  renew_attempts: number;
  created_at: string;
  updated_at: string;
  canceled_at: string | null;
}

interface BeginResult {
  redirectUrl: string;
  payload?: PaymentFormFields;
}

interface ChargeResult {
  success: boolean;
  transactionRef?: string;
  errorCode?: string;
}

type RecoveryResult = ChargeResult | "pending" | null;

interface PaymentProvider {
  readonly name: "mock" | "mypos";
  begin(order: OrderRow, products: Product[]): Promise<BeginResult>;
  renew(order: OrderRow, subscription: SubscriptionRow, cardToken: string, products: Product[]): Promise<ChargeResult>;
  recover(order: OrderRow): Promise<RecoveryResult>;
}

interface WebhookProvider extends PaymentProvider {
  readonly checkoutUrl: string;
  validateWebhook(formData: FormData): WebhookValidationResult;
}

interface ServiceOptions {
  env?: Record<string, string | undefined>;
  dbPath?: string;
  catalog?: Catalog;
  catalogPath?: string;
  publicUrl?: string;
  upstreamUrl?: string;
  now?: () => Date;
}

export interface PaymentService {
  fetch(request: Request): Promise<Response>;
  runRenewals(): Promise<void>;
  close(): void;
  provider: "mock" | "mypos";
}

class Store {
  private readonly db: Database;
  private readonly now: () => Date;

  constructor(path: string, now: () => Date) {
    mkdirSync(dirname(path), { recursive: true });
    this.db = new Database(path, { create: true, strict: true });
    this.now = now;
    this.db.exec(`
      PRAGMA journal_mode = WAL;
      PRAGMA foreign_keys = ON;
      CREATE TABLE IF NOT EXISTS payment_orders (
        id TEXT PRIMARY KEY,
        kind TEXT NOT NULL CHECK (kind IN ('initial', 'renewal')),
        subscription_id TEXT,
        customer_name TEXT NOT NULL,
        customer_email TEXT NOT NULL,
        company TEXT,
        interval TEXT NOT NULL CHECK (interval IN ('monthly', 'annual')),
        products_json TEXT NOT NULL,
        unit_cents INTEGER NOT NULL CHECK (unit_cents > 0),
        amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
        currency TEXT NOT NULL CHECK (currency = 'EUR'),
        status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'failed', 'canceled', 'rolled_back')),
        provider TEXT NOT NULL,
        provider_ref TEXT,
        provider_payload TEXT,
        manage_token_hash TEXT NOT NULL,
        period_start TEXT,
        error_code TEXT,
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        paid_at TEXT
      );
      CREATE TABLE IF NOT EXISTS subscriptions (
        id TEXT PRIMARY KEY,
        initial_order_id TEXT NOT NULL UNIQUE REFERENCES payment_orders(id),
        customer_email TEXT NOT NULL,
        interval TEXT NOT NULL CHECK (interval IN ('monthly', 'annual')),
        products_json TEXT NOT NULL,
        unit_cents INTEGER NOT NULL CHECK (unit_cents > 0),
        amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
        currency TEXT NOT NULL CHECK (currency = 'EUR'),
        status TEXT NOT NULL CHECK (status IN ('active', 'past_due', 'unpaid', 'canceled', 'requires_payment_method')),
        card_token_enc TEXT,
        manage_token_hash TEXT NOT NULL UNIQUE,
        next_charge_at TEXT NOT NULL,
        retry_at TEXT,
        renew_attempts INTEGER NOT NULL DEFAULT 0,
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        canceled_at TEXT
      );
      CREATE INDEX IF NOT EXISTS payment_orders_subscription_idx ON payment_orders(subscription_id, status);
      CREATE INDEX IF NOT EXISTS subscriptions_due_idx ON subscriptions(status, next_charge_at, retry_at);
    `);
  }

  createInitial(quote: CheckoutQuote, provider: string, manageTokenHash: string): OrderRow {
    const id = `ord_${randomUUID()}`;
    const timestamp = this.now().toISOString();
    this.db.query(`
      INSERT INTO payment_orders (
        id, kind, customer_name, customer_email, company, interval, products_json,
        unit_cents, amount_cents, currency, status, provider, manage_token_hash,
        created_at, updated_at
      ) VALUES (?, 'initial', ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)
    `).run(
      id,
      quote.customer.name,
      quote.customer.email,
      quote.customer.company ?? null,
      quote.interval,
      JSON.stringify(quote.productIds),
      quote.unitCents,
      quote.amountCents,
      quote.currency,
      provider,
      manageTokenHash,
      timestamp,
      timestamp,
    );
    return this.order(id)!;
  }

  createRenewal(subscription: SubscriptionRow): OrderRow {
    const attempt = subscription.renew_attempts + 1;
    const periodKey = subscription.next_charge_at.slice(0, 10).replaceAll("-", "");
    const id = `ren_${subscription.id.slice(4, 16)}_${periodKey}_${attempt}`;
    const timestamp = this.now().toISOString();
    this.db.query(`
      INSERT OR IGNORE INTO payment_orders (
        id, kind, subscription_id, customer_name, customer_email, interval, products_json,
        unit_cents, amount_cents, currency, status, provider, manage_token_hash,
        period_start, created_at, updated_at
      ) VALUES (?, 'renewal', ?, '', ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)
    `).run(
      id,
      subscription.id,
      subscription.customer_email,
      subscription.interval,
      subscription.products_json,
      subscription.unit_cents,
      subscription.amount_cents,
      subscription.currency,
      providerNameFromInitial(this.db, subscription.initial_order_id),
      subscription.manage_token_hash,
      subscription.next_charge_at,
      timestamp,
      timestamp,
    );
    return this.order(id)!;
  }

  order(id: string): OrderRow | null {
    return (this.db.query("SELECT * FROM payment_orders WHERE id = ?").get(id) as OrderRow | null) ?? null;
  }

  setPayload(id: string, payload: PaymentFormFields): void {
    this.db.query("UPDATE payment_orders SET provider_payload = ?, updated_at = ? WHERE id = ? AND status = 'pending'")
      .run(JSON.stringify(payload), this.now().toISOString(), id);
  }

  markOrder(id: string, status: Exclude<OrderStatus, "paid">, errorCode?: string): void {
    const timestamp = this.now().toISOString();
    this.db.query("UPDATE payment_orders SET status = ?, error_code = ?, updated_at = ? WHERE id = ? AND status = 'pending'")
      .run(status, errorCode ?? null, timestamp, id);
  }

  rollbackOrder(id: string, transactionRef?: string): void {
    const timestamp = this.now().toISOString();
    const transaction = this.db.transaction(() => {
      this.db.query("UPDATE payment_orders SET status = 'rolled_back', provider_ref = ?, updated_at = ? WHERE id = ?")
        .run(transactionRef ?? null, timestamp, id);
      this.db.query("UPDATE subscriptions SET status = 'canceled', canceled_at = ?, updated_at = ? WHERE initial_order_id = ?")
        .run(timestamp, timestamp, id);
    });
    transaction();
  }

  completeInitial(order: OrderRow, encryptedCardToken: string | null, transactionRef?: string): SubscriptionRow {
    const timestamp = this.now().toISOString();
    const subscriptionId = `sub_${order.id.slice(4)}`;
    const nextChargeAt = nextBillingDate(this.now(), order.interval).toISOString();
    const status: SubscriptionStatus = encryptedCardToken ? "active" : "requires_payment_method";
    const transaction = this.db.transaction(() => {
      const current = this.order(order.id);
      if (!current) throw new Error("Order not found");
      if (current.status === "paid") return;
      if (current.status !== "pending") throw new Error("Order is not payable");
      this.db.query(`
        INSERT INTO subscriptions (
          id, initial_order_id, customer_email, interval, products_json, unit_cents,
          amount_cents, currency, status, card_token_enc, manage_token_hash,
          next_charge_at, created_at, updated_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      `).run(
        subscriptionId,
        order.id,
        order.customer_email,
        order.interval,
        order.products_json,
        order.unit_cents,
        order.amount_cents,
        order.currency,
        status,
        encryptedCardToken,
        order.manage_token_hash,
        nextChargeAt,
        timestamp,
        timestamp,
      );
      this.db.query("UPDATE payment_orders SET status = 'paid', provider_ref = ?, paid_at = ?, updated_at = ? WHERE id = ?")
        .run(transactionRef ?? null, timestamp, timestamp, order.id);
    });
    transaction();
    return this.subscriptionByOrder(order.id)!;
  }

  completeRenewal(order: OrderRow, transactionRef?: string): void {
    if (!order.subscription_id || !order.period_start) throw new Error("Invalid renewal order");
    const timestamp = this.now().toISOString();
    const nextChargeAt = nextBillingDate(new Date(order.period_start), order.interval).toISOString();
    const transaction = this.db.transaction(() => {
      const current = this.order(order.id);
      if (!current) throw new Error("Renewal order not found");
      if (current.status === "paid") return;
      if (current.status !== "pending") throw new Error("Renewal order is not payable");
      this.db.query("UPDATE payment_orders SET status = 'paid', provider_ref = ?, paid_at = ?, updated_at = ? WHERE id = ?")
        .run(transactionRef ?? null, timestamp, timestamp, order.id);
      this.db.query(`
        UPDATE subscriptions
        SET status = 'active', next_charge_at = ?, retry_at = NULL, renew_attempts = 0, updated_at = ?
        WHERE id = ? AND status != 'canceled'
      `).run(nextChargeAt, timestamp, order.subscription_id);
    });
    transaction();
  }

  failRenewal(order: OrderRow, errorCode: string): void {
    if (!order.subscription_id) throw new Error("Invalid renewal order");
    const subscription = this.subscription(order.subscription_id);
    if (!subscription || subscription.status === "canceled") return;
    const attempts = subscription.renew_attempts + 1;
    const terminal = attempts >= 3;
    const retryDays = attempts === 1 ? 1 : 3;
    const retryAt = terminal ? null : new Date(this.now().getTime() + retryDays * 86_400_000).toISOString();
    const timestamp = this.now().toISOString();
    const transaction = this.db.transaction(() => {
      this.db.query("UPDATE payment_orders SET status = 'failed', error_code = ?, updated_at = ? WHERE id = ? AND status = 'pending'")
        .run(errorCode.slice(0, 80), timestamp, order.id);
      this.db.query(`
        UPDATE subscriptions
        SET status = ?, renew_attempts = ?, retry_at = ?, updated_at = ?
        WHERE id = ? AND status != 'canceled'
      `).run(terminal ? "unpaid" : "past_due", attempts, retryAt, timestamp, order.subscription_id);
    });
    transaction();
  }

  subscription(id: string): SubscriptionRow | null {
    return (this.db.query("SELECT * FROM subscriptions WHERE id = ?").get(id) as SubscriptionRow | null) ?? null;
  }

  subscriptionByOrder(orderId: string): SubscriptionRow | null {
    return (this.db.query("SELECT * FROM subscriptions WHERE initial_order_id = ?").get(orderId) as SubscriptionRow | null) ?? null;
  }

  subscriptionByManageHash(hash: string): SubscriptionRow | null {
    return (this.db.query("SELECT * FROM subscriptions WHERE manage_token_hash = ?").get(hash) as SubscriptionRow | null) ?? null;
  }

  dueSubscriptions(): SubscriptionRow[] {
    const timestamp = this.now().toISOString();
    return this.db.query(`
      SELECT * FROM subscriptions
      WHERE (status = 'active' AND next_charge_at <= ?)
         OR (status = 'past_due' AND retry_at IS NOT NULL AND retry_at <= ?)
      ORDER BY COALESCE(retry_at, next_charge_at)
      LIMIT 50
    `).all(timestamp, timestamp) as SubscriptionRow[];
  }

  pendingRenewal(subscriptionId: string): OrderRow | null {
    return (this.db.query(`
      SELECT * FROM payment_orders
      WHERE subscription_id = ? AND kind = 'renewal' AND status = 'pending'
      ORDER BY created_at DESC LIMIT 1
    `).get(subscriptionId) as OrderRow | null) ?? null;
  }

  cancelSubscription(manageTokenHash: string): SubscriptionRow | null {
    const timestamp = this.now().toISOString();
    this.db.query(`
      UPDATE subscriptions
      SET status = 'canceled', canceled_at = ?, retry_at = NULL, updated_at = ?
      WHERE manage_token_hash = ? AND status != 'canceled'
    `).run(timestamp, timestamp, manageTokenHash);
    return this.subscriptionByManageHash(manageTokenHash);
  }

  close(): void {
    this.db.close();
  }
}

class MockProvider implements PaymentProvider {
  readonly name = "mock" as const;

  constructor(private readonly publicUrl: string) {}

  async begin(order: OrderRow): Promise<BeginResult> {
    return { redirectUrl: `${this.publicUrl}/api/payments/mock/checkout/${encodeURIComponent(order.id)}` };
  }

  async renew(order: OrderRow): Promise<ChargeResult> {
    return { success: true, transactionRef: `mock_${order.id}` };
  }

  async recover(): Promise<RecoveryResult> {
    return null;
  }
}

class MyPOSProvider implements WebhookProvider {
  readonly name = "mypos" as const;
  readonly checkoutUrl: string;
  private readonly client: MyPOSClient;

  constructor(private readonly publicUrl: string, env: Record<string, string | undefined>) {
    const testMode = env.MYPOS_TEST_MODE === "true";
    if (!publicUrl.startsWith("https://") || new URL(publicUrl).port) {
      throw new Error("myPOS mode requires an HTTPS PAYMENTS_PUBLIC_URL without a port");
    }
    if (env.MYPOS_CONFIG_PACK) {
      this.client = new MyPOSClient({ configPack: env.MYPOS_CONFIG_PACK, testMode, source: "AkurAI Platform" });
    } else {
      const storeId = required(env, "MYPOS_STORE_ID");
      const walletNumber = required(env, "MYPOS_WALLET_NUMBER");
      const privateKey = required(env, "MYPOS_PRIVATE_KEY").replaceAll("\\n", "\n");
      const publicCertificate = required(env, "MYPOS_PUBLIC_CERT").replaceAll("\\n", "\n");
      const keyIndex = Number(required(env, "MYPOS_KEY_INDEX"));
      if (!Number.isInteger(keyIndex) || keyIndex < 1) throw new Error("MYPOS_KEY_INDEX must be a positive integer");
      this.client = new MyPOSClient({ storeId, walletNumber, privateKey, publicCertificate, keyIndex, testMode, source: "AkurAI Platform" });
    }
    this.checkoutUrl = this.client.checkoutUrl;
  }

  async begin(order: OrderRow, products: Product[]): Promise<BeginResult> {
    const unitAmount = order.unit_cents / 100;
    const fields = this.client.generatePurchaseFields(
      { items: products.map((product) => ({ article: `${product.name} ${order.interval} subscription`, quantity: 1, price: unitAmount, amount: unitAmount })) },
      order.id,
      {
        urlOk: `${this.publicUrl}/checkout/success?order=${encodeURIComponent(order.id)}`,
        urlCancel: `${this.publicUrl}/checkout/cancel?order=${encodeURIComponent(order.id)}`,
        urlNotify: `${this.publicUrl}/api/payments/webhook`,
        currency: order.currency,
        language: "EN",
        cardTokenRequest: 2,
        paymentParametersRequired: 2,
        paymentMethod: 1,
        customerEmail: order.customer_email,
        note: `AkurAI ${order.interval} subscription`,
        expiresIn: 900,
      },
    );
    return {
      redirectUrl: `${this.publicUrl}/api/payments/mypos/redirect/${encodeURIComponent(order.id)}`,
      payload: fields,
    };
  }

  validateWebhook(formData: FormData): WebhookValidationResult {
    return this.client.validateWebhook(formData);
  }

  async renew(order: OrderRow, subscription: SubscriptionRow, cardToken: string, products: Product[]): Promise<ChargeResult> {
    const unitAmount = subscription.unit_cents / 100;
    const result = await this.client.iaPurchase({
      cardToken,
      orderId: order.id,
      cart: { items: products.map((product) => ({ article: `${product.name} ${subscription.interval} renewal`, quantity: 1, price: unitAmount, amount: unitAmount })) },
      currency: subscription.currency,
      language: "EN",
      note: `AkurAI ${subscription.interval} renewal`,
    });
    return result.success
      ? { success: true, transactionRef: result.ipcTrnref }
      : { success: false, errorCode: `mypos_${result.status}` };
  }

  async recover(order: OrderRow): Promise<RecoveryResult> {
    const result = await this.client.getPaymentStatus({ orderId: order.id });
    if (!result.success) return null;
    if (result.paymentStatus === 1) return { success: true, transactionRef: result.ipcTrnref };
    if (result.paymentStatus === 2) return "pending";
    if (result.paymentStatus === 3 || result.paymentStatus === 4) return { success: false, errorCode: `mypos_payment_${result.paymentStatus}` };
    return null;
  }
}

export function createPaymentService(options: ServiceOptions = {}): PaymentService {
  const env = options.env ?? Bun.env;
  const now = options.now ?? (() => new Date());
  const publicUrl = trimSlash(options.publicUrl ?? env.PAYMENTS_PUBLIC_URL ?? `http://127.0.0.1:${env.PAYMENTS_PORT ?? "8198"}`);
  const upstreamUrl = trimSlash(options.upstreamUrl ?? env.PLATFORM_UPSTREAM ?? "http://127.0.0.1:8098");
  const catalogPath = options.catalogPath ?? env.PAYMENTS_CATALOG;
  const catalog = options.catalog ?? (catalogPath ? loadCatalog(catalogPath) : validateCatalog(embeddedCatalog));
  const provider = env.PAYMENTS_PROVIDER === "mypos" ? new MyPOSProvider(publicUrl, env) : new MockProvider(publicUrl);
  const key = tokenKey(env, provider.name);
  const store = new Store(resolve(options.dbPath ?? env.PAYMENTS_DB ?? "data/payments.db"), now);
  const productById = new Map(catalog.products.map((product) => [product.id, product]));

  async function completeInitial(order: OrderRow, cardToken: string | undefined, transactionRef?: string): Promise<void> {
    const encrypted = cardToken ? encrypt(cardToken, key) : null;
    store.completeInitial(order, encrypted, transactionRef);
  }

  async function fetchHandler(request: Request): Promise<Response> {
    const url = new URL(request.url);
    try {
      if (request.method === "GET" && url.pathname === "/api/payments/health") {
        return json({ ok: true, provider: provider.name, products: catalog.products.filter((product) => product.available !== false).length });
      }
      if (request.method === "GET" && url.pathname === "/api/payments/catalog") {
        return json({
          currency: catalog.currency,
          monthlyUnitCents: catalog.monthly_unit_cents,
          annualUnitCents: Math.round(catalog.monthly_unit_cents * 12 * (100 - catalog.annual_discount_percent) / 100),
          annualDiscountPercent: catalog.annual_discount_percent,
          products: catalog.products,
          provider: provider.name,
        });
      }
      if (request.method === "POST" && url.pathname === "/api/payments/checkout") {
        if (Number(request.headers.get("content-length") ?? 0) > 16_384) return json({ error: "Checkout request is too large" }, 413);
        if (!request.headers.get("content-type")?.startsWith("application/json")) return json({ error: "Expected application/json" }, 415);
        let body: unknown;
        try {
          body = await request.json();
        } catch {
          return json({ error: "Invalid JSON" }, 400);
        }
        let quote: CheckoutQuote;
        try {
          quote = quoteCheckout(catalog, body);
        } catch (error) {
          return json({ error: errorMessage(error) }, 400);
        }
        const manageToken = randomBytes(32).toString("base64url");
        const order = store.createInitial(quote, provider.name, hashToken(manageToken));
        try {
          const begin = await provider.begin(order, quote.products);
          if (begin.payload) store.setPayload(order.id, begin.payload);
          return json({
            orderId: order.id,
            redirectUrl: begin.redirectUrl,
            manageToken,
            amountCents: order.amount_cents,
            currency: order.currency,
          }, 201);
        } catch (error) {
          store.markOrder(order.id, "failed", "checkout_initialization_failed");
          console.error(JSON.stringify({ event: "payment_checkout_failed", orderId: order.id, provider: provider.name, error: safeError(error) }));
          return json({ error: "Payment checkout could not be started" }, 502);
        }
      }

      const orderStatus = matchPath(url.pathname, "/api/payments/orders/");
      if (request.method === "GET" && orderStatus) {
        const order = store.order(orderStatus);
        if (!order) return json({ error: "Order not found" }, 404);
        return json(publicOrder(order, productById));
      }

      if (request.method === "POST" && url.pathname === "/api/payments/webhook") {
        if (provider.name !== "mypos") return new Response("Not available", { status: 404 });
        const result = (provider as MyPOSProvider).validateWebhook(await request.formData());
        if (!result.isValid) return new Response("Invalid signature", { status: 400 });
        const order = store.order(result.orderId);
        if (!order) return new Response("Order not found", { status: 404 });
        if (Math.round(result.amount * 100) !== order.amount_cents || result.currency !== order.currency) {
          return new Response("Payment mismatch", { status: 409 });
        }
        if (result.method === "IPCPurchaseNotify") {
          await completeInitial(order, result.cardToken, result.transactionRef);
        } else if (result.method === "IPCPurchaseRollback") {
          store.rollbackOrder(order.id, result.transactionRef);
        } else {
          return new Response("Unsupported notification", { status: 400 });
        }
        return new Response("OK", { headers: { "content-type": "text/plain; charset=utf-8" } });
      }

      const myposRedirect = matchPath(url.pathname, "/api/payments/mypos/redirect/");
      if (request.method === "GET" && myposRedirect && provider.name === "mypos") {
        const order = store.order(myposRedirect);
        if (!order || order.status !== "pending" || !order.provider_payload) return htmlPage("Checkout unavailable", "This checkout session is no longer available.", 404);
        const fields = JSON.parse(order.provider_payload) as PaymentFormFields;
        return paymentRedirectPage((provider as MyPOSProvider).checkoutUrl, fields);
      }

      const mockCheckout = matchPath(url.pathname, "/api/payments/mock/checkout/");
      if (request.method === "GET" && mockCheckout && provider.name === "mock") {
        const order = store.order(mockCheckout);
        if (!order || order.status !== "pending") return htmlPage("Checkout unavailable", "This mock checkout session is no longer available.", 404);
        return mockCheckoutPage(order, productById);
      }

      const mockOutcome = url.pathname.match(/^\/api\/payments\/mock\/orders\/([^/]+)\/(approve|decline|cancel)$/);
      if (request.method === "POST" && mockOutcome && provider.name === "mock") {
        const order = store.order(decodeURIComponent(mockOutcome[1]));
        if (!order) return htmlPage("Order not found", "The mock order does not exist.", 404);
        const outcome = mockOutcome[2];
        if (outcome === "approve" && order.status === "pending") {
          await completeInitial(order, `mock_card_${order.id}`, `mock_txn_${order.id}`);
          return redirect(`${publicUrl}/checkout/success?order=${encodeURIComponent(order.id)}`);
        }
        if (outcome === "decline" && order.status === "pending") store.markOrder(order.id, "failed", "mock_declined");
        if (outcome === "cancel" && order.status === "pending") store.markOrder(order.id, "canceled", "customer_canceled");
        return redirect(`${publicUrl}/checkout/${outcome === "cancel" ? "cancel" : "success"}?order=${encodeURIComponent(order.id)}`);
      }

      const manageApi = matchPath(url.pathname, "/api/payments/subscriptions/");
      if (manageApi && validManageToken(manageApi)) {
        const hash = hashToken(manageApi);
        if (request.method === "GET") {
          const subscription = store.subscriptionByManageHash(hash);
          if (!subscription) return json({ error: "Subscription not found" }, 404);
          return json(publicSubscription(subscription, productById));
        }
        if (request.method === "POST" && url.searchParams.get("action") === "cancel") {
          const subscription = store.cancelSubscription(hash);
          if (!subscription) return json({ error: "Subscription not found" }, 404);
          if (request.headers.get("accept")?.includes("application/json")) return json(publicSubscription(subscription, productById));
          return redirect(`${publicUrl}/subscriptions/manage/${encodeURIComponent(manageApi)}`);
        }
      }

      const managePage = matchPath(url.pathname, "/subscriptions/manage/");
      if (request.method === "GET" && managePage && validManageToken(managePage)) {
        const subscription = store.subscriptionByManageHash(hashToken(managePage));
        if (!subscription) return htmlPage("Subscription not found", "This management link is invalid or has expired.", 404);
        return subscriptionPage(subscription, managePage, productById);
      }

      if (request.method === "GET" && url.pathname === "/checkout/success") return checkoutResultPage("success");
      if (request.method === "GET" && url.pathname === "/checkout/cancel") return checkoutResultPage("cancel");

      return proxy(request, upstreamUrl);
    } catch (error) {
      console.error(JSON.stringify({ event: "payment_request_error", path: url.pathname, error: safeError(error) }));
      return json({ error: "Payment service error" }, 500);
    }
  }

  async function runRenewals(): Promise<void> {
    for (const subscription of store.dueSubscriptions()) {
      if (!subscription.card_token_enc) continue;
      try {
        let order = store.pendingRenewal(subscription.id);
        if (order) {
          const recovered = await provider.recover(order);
          if (recovered === "pending") continue;
          if (recovered?.success) {
            store.completeRenewal(order, recovered.transactionRef);
            continue;
          }
          if (recovered && !recovered.success) {
            store.failRenewal(order, recovered.errorCode ?? "renewal_failed");
            continue;
          }
          store.failRenewal(order, "renewal_recovery_failed");
          continue;
        }
        order = store.createRenewal(subscription);
        const products = productsFor(JSON.parse(subscription.products_json) as string[], productById);
        const cardToken = decrypt(subscription.card_token_enc, key);
        const result = await provider.renew(order, subscription, cardToken, products);
        if (result.success) store.completeRenewal(order, result.transactionRef);
        else store.failRenewal(order, result.errorCode ?? "renewal_failed");
      } catch (error) {
        console.error(JSON.stringify({ event: "payment_renewal_error", subscriptionId: subscription.id, provider: provider.name, error: safeError(error) }));
      }
    }
  }

  return {
    fetch: fetchHandler,
    runRenewals,
    close: () => store.close(),
    provider: provider.name,
  };
}

function loadCatalog(path: string): Catalog {
  return validateCatalog(JSON.parse(readFileSync(path, "utf8")));
}

function providerNameFromInitial(db: Database, orderId: string): string {
  const row = db.query("SELECT provider FROM payment_orders WHERE id = ?").get(orderId) as { provider: string } | null;
  if (!row) throw new Error("Initial order not found");
  return row.provider;
}

function productsFor(ids: string[], products: Map<string, Product>): Product[] {
  const result = ids.map((id) => products.get(id));
  if (result.some((product) => !product)) throw new Error("Subscription contains an unknown product");
  return result as Product[];
}

function publicOrder(order: OrderRow, products: Map<string, Product>): Record<string, unknown> {
  const ids = JSON.parse(order.products_json) as string[];
  return {
    id: order.id,
    status: order.status,
    interval: order.interval,
    amountCents: order.amount_cents,
    currency: order.currency,
    products: productsFor(ids, products).map(({ id, name }) => ({ id, name })),
    createdAt: order.created_at,
    paidAt: order.paid_at,
  };
}

function publicSubscription(subscription: SubscriptionRow, products: Map<string, Product>): Record<string, unknown> {
  const ids = JSON.parse(subscription.products_json) as string[];
  return {
    id: subscription.id,
    status: subscription.status,
    interval: subscription.interval,
    amountCents: subscription.amount_cents,
    currency: subscription.currency,
    email: maskEmail(subscription.customer_email),
    products: productsFor(ids, products).map(({ id, name }) => ({ id, name })),
    nextChargeAt: subscription.next_charge_at,
    canceledAt: subscription.canceled_at,
  };
}

function tokenKey(env: Record<string, string | undefined>, provider: "mock" | "mypos"): Buffer {
  if (env.PAYMENTS_TOKEN_KEY) {
    const key = Buffer.from(env.PAYMENTS_TOKEN_KEY, "base64");
    if (key.length !== 32) throw new Error("PAYMENTS_TOKEN_KEY must decode to exactly 32 bytes");
    return key;
  }
  if (provider === "mypos") throw new Error("PAYMENTS_TOKEN_KEY is required in myPOS mode");
  return createHash("sha256").update("akurai-platform-mock-token-key").digest();
}

function encrypt(value: string, key: Buffer): string {
  const iv = randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", key, iv);
  const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
  return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString("base64url");
}

function decrypt(value: string, key: Buffer): string {
  const payload = Buffer.from(value, "base64url");
  if (payload.length < 29) throw new Error("Invalid encrypted card token");
  const decipher = createDecipheriv("aes-256-gcm", key, payload.subarray(0, 12));
  decipher.setAuthTag(payload.subarray(12, 28));
  return Buffer.concat([decipher.update(payload.subarray(28)), decipher.final()]).toString("utf8");
}

function hashToken(token: string): string {
  return createHash("sha256").update(token).digest("hex");
}

function validManageToken(token: string): boolean {
  return /^[A-Za-z0-9_-]{43}$/.test(token);
}

function required(env: Record<string, string | undefined>, key: string): string {
  const value = env[key];
  if (!value) throw new Error(`${key} is required in myPOS mode`);
  return value;
}

function matchPath(pathname: string, prefix: string): string | null {
  if (!pathname.startsWith(prefix)) return null;
  const value = pathname.slice(prefix.length);
  if (!value || value.includes("/")) return null;
  try {
    return decodeURIComponent(value);
  } catch {
    return null;
  }
}

function json(value: unknown, status = 200): Response {
  return new Response(JSON.stringify(value), {
    status,
    headers: {
      "content-type": "application/json; charset=utf-8",
      "cache-control": "no-store",
      "x-content-type-options": "nosniff",
    },
  });
}

function redirect(location: string): Response {
  return new Response(null, { status: 303, headers: { location, "cache-control": "no-store" } });
}

async function proxy(request: Request, upstream: string): Promise<Response> {
  const source = new URL(request.url);
  const target = `${upstream}${source.pathname}${source.search}`;
  const headers = new Headers(request.headers);
  headers.delete("host");
  return fetch(target, {
    method: request.method,
    headers,
    body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
    redirect: "manual",
  });
}

function paymentRedirectPage(action: string, fields: PaymentFormFields): Response {
  const nonce = randomBytes(18).toString("base64url");
  const inputs = Object.entries(fields).map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`).join("");
  const origin = new URL(action).origin;
  return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Opening secure checkout</title></head><body><form id="checkout" method="post" action="${escapeHtml(action)}">${inputs}<button type="submit">Continue to secure checkout</button></form><script nonce="${nonce}">document.getElementById("checkout").submit()</script></body></html>`, {
    headers: htmlHeaders(`default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; form-action ${origin}; base-uri 'none'`),
  });
}

function mockCheckoutPage(order: OrderRow, products: Map<string, Product>): Response {
  const names = productsFor(JSON.parse(order.products_json) as string[], products).map((product) => product.name);
  const rows = names.map((name) => `<li>${escapeHtml(name)}</li>`).join("");
  const base = `/api/payments/mock/orders/${encodeURIComponent(order.id)}`;
  return themedPage("myPOS sandbox simulation", `
    <span class="eyebrow">Test payment</span>
    <h1>Complete your AkurAI subscription</h1>
    <p class="lede">This local gateway follows the production redirect and callback flow. No money or card data is used.</p>
    <div class="panel"><ul>${rows}</ul><div class="total"><span>${order.interval === "annual" ? "Annual" : "Monthly"} total</span><strong>${money(order.amount_cents, order.currency)}</strong></div></div>
    <div class="test-card"><span>Sandbox test card</span><strong>•••• 4242</strong></div>
    <div class="actions">
      <form method="post" action="${base}/approve"><button class="primary" type="submit">Approve test payment</button></form>
      <form method="post" action="${base}/decline"><button type="submit">Decline</button></form>
      <form method="post" action="${base}/cancel"><button type="submit">Cancel</button></form>
    </div>
  `);
}

function subscriptionPage(subscription: SubscriptionRow, token: string, products: Map<string, Product>): Response {
  const names = productsFor(JSON.parse(subscription.products_json) as string[], products).map((product) => product.name);
  const rows = names.map((name) => `<li>${escapeHtml(name)}</li>`).join("");
  const active = subscription.status !== "canceled";
  return themedPage("Manage subscription", `
    <span class="eyebrow">Subscription</span>
    <h1>${active ? "Manage your plan" : "Subscription canceled"}</h1>
    <p class="lede">${escapeHtml(maskEmail(subscription.customer_email))} · ${subscription.interval} billing</p>
    <div class="panel"><ul>${rows}</ul><div class="total"><span>${active ? "Next charge" : "Final status"}</span><strong>${active ? `${money(subscription.amount_cents)} · ${escapeHtml(subscription.next_charge_at.slice(0, 10))}` : "Canceled"}</strong></div></div>
    <div class="actions">
      ${active ? `<form method="post" action="/api/payments/subscriptions/${encodeURIComponent(token)}?action=cancel"><button class="danger" type="submit">Cancel automatic renewal</button></form>` : ""}
      <a class="button" href="/">Back to AkurAI Platform</a>
    </div>
  `);
}

function checkoutResultPage(kind: "success" | "cancel"): Response {
  const nonce = randomBytes(18).toString("base64url");
  const initialTitle = kind === "cancel" ? "Checkout canceled" : "Confirming payment";
  const initialText = kind === "cancel" ? "No payment was taken. You can return and try again when ready." : "Waiting for the secure payment confirmation.";
  const script = kind === "success" ? `
    const params=new URLSearchParams(location.search);const order=params.get("order");
    const title=document.getElementById("result-title");const text=document.getElementById("result-text");const manage=document.getElementById("manage");
    if(order){let checks=0;const check=async()=>{try{const response=await fetch("/api/payments/orders/"+encodeURIComponent(order),{headers:{accept:"application/json"}});const data=await response.json();if(data.status==="paid"){title.textContent="Subscription active";text.textContent="Payment confirmed. Your selected AkurAI apps are now subscribed.";const token=sessionStorage.getItem("akurai_manage_"+order);if(token){manage.href="/subscriptions/manage/"+encodeURIComponent(token);manage.hidden=false}return}if(["failed","canceled","rolled_back"].includes(data.status)){title.textContent="Payment not completed";text.textContent="The payment was not completed. No subscription renewal is active.";return}}catch{}if(++checks<15)setTimeout(check,1000);else{text.textContent="Confirmation is taking longer than expected. Keep this order reference: "+order}};check()}
  ` : "";
  return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${initialTitle}</title><style>${THEME_CSS}</style></head><body><main class="card"><span class="eyebrow">AkurAI Platform</span><h1 id="result-title">${initialTitle}</h1><p id="result-text" class="lede">${initialText}</p><div class="actions"><a id="manage" class="button primary" href="#" hidden>Manage subscription</a><a class="button" href="/">Back to platform</a></div></main>${script ? `<script nonce="${nonce}">${script}</script>` : ""}</body></html>`, {
    headers: htmlHeaders(`default-src 'self'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'`),
  });
}

function htmlPage(title: string, text: string, status: number): Response {
  const response = themedPage(title, `<span class="eyebrow">AkurAI Platform</span><h1>${escapeHtml(title)}</h1><p class="lede">${escapeHtml(text)}</p><a class="button" href="/">Back to platform</a>`);
  return new Response(response.body, { status, headers: response.headers });
}

function themedPage(title: string, body: string): Response {
  return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title><style>${THEME_CSS}</style></head><body><main class="card">${body}</main></body></html>`, {
    headers: htmlHeaders("default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"),
  });
}

function htmlHeaders(csp: string): Headers {
  return new Headers({
    "content-type": "text/html; charset=utf-8",
    "cache-control": "no-store",
    "content-security-policy": csp,
    "referrer-policy": "no-referrer",
    "x-content-type-options": "nosniff",
    "x-frame-options": "DENY",
  });
}

function escapeHtml(value: string): string {
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}

function trimSlash(value: string): string {
  return value.replace(/\/+$/, "");
}

function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : "Invalid checkout details";
}

function safeError(error: unknown): string {
  return (error instanceof Error ? error.message : String(error)).slice(0, 240).replace(/[\r\n]/g, " ");
}

const THEME_CSS = `
  :root{color-scheme:dark;--bg:#080b12;--panel:#111722;--line:#273244;--text:#f7f9fc;--muted:#9aa8bc;--accent:#6ee7c2;--danger:#ff8a8a;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif}
  *{box-sizing:border-box}body{min-height:100vh;margin:0;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at 20% 0,#142536 0,transparent 38%),var(--bg);color:var(--text)}
  .card{width:min(620px,100%);padding:clamp(28px,6vw,56px);border:1px solid var(--line);border-radius:24px;background:color-mix(in srgb,var(--panel) 94%,transparent);box-shadow:0 24px 80px #0008}
  .eyebrow{color:var(--accent);font-size:.75rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:.6rem 0 1rem;font-size:clamp(2rem,6vw,3.5rem);line-height:1.02}.lede{color:var(--muted);font-size:1.08rem}
  .panel,.test-card{margin:24px 0;padding:20px;border:1px solid var(--line);border-radius:16px;background:#090d15}.panel ul{margin:0 0 18px;padding-left:20px}.total,.test-card{display:flex;justify-content:space-between;gap:20px}.total{padding-top:16px;border-top:1px solid var(--line)}.total span,.test-card span{color:var(--muted)}
  .actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:28px}.actions form{margin:0}.button,button{appearance:none;display:inline-flex;align-items:center;justify-content:center;min-height:46px;padding:0 18px;border:1px solid var(--line);border-radius:12px;background:#171e2b;color:var(--text);font:inherit;font-weight:750;text-decoration:none;cursor:pointer}.primary{border-color:var(--accent);background:var(--accent);color:#06120f}.danger{border-color:color-mix(in srgb,var(--danger) 55%,var(--line));color:var(--danger)}button:hover,.button:hover{filter:brightness(1.12)}[hidden]{display:none!important}
`;

if (import.meta.main) {
  const port = Number(Bun.env.PAYMENTS_PORT ?? 8198);
  if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("PAYMENTS_PORT must be a valid port");
  const hostname = Bun.env.PAYMENTS_HOST ?? "127.0.0.1";
  const service = createPaymentService();
  const server = Bun.serve({ hostname, port, fetch: service.fetch });
  const renewalTimer = setInterval(() => void service.runRenewals(), 60 * 60 * 1000);
  renewalTimer.unref();
  const shutdown = () => {
    clearInterval(renewalTimer);
    service.close();
    void server.stop();
  };
  process.once("SIGINT", shutdown);
  process.once("SIGTERM", shutdown);
  console.log(JSON.stringify({ service: "akurai-platform-payments", provider: service.provider, hostname, port: server.port }));
}