From 7bf01f8d6e91309729744165e778f5151d7d85f8 Mon Sep 17 00:00:00 2001 From: gidson5 <46024944+gidson5@users.noreply.github.com> Date: Fri, 29 May 2026 11:50:22 +0100 Subject: [PATCH] feat: comprehensive API docs and custom theme system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #380, closes #379 ## #380 — Comprehensive API documentation - docs/api/openapi.yaml: OpenAPI 3.0.3 specification covering subscriptions, plans, customers, billing/invoices, webhooks, and themes endpoints with full request/response schemas, error responses, and rate-limit docs - docs/api/swagger.html: self-contained Swagger UI interactive explorer (load openapi.yaml, try-it-out enabled, dark-themed) - docs/api/webhooks.md: full webhook event reference — subscription.created, subscription.updated, subscription.cancelled, subscription.paused, subscription.resumed, subscription.trial_will_end, subscription.expired, invoice.created, invoice.paid, invoice.payment_failed, customer.created; includes signature verification, retry policy, and idempotency pattern - docs/api/sdk/javascript.md: JS/TS SDK examples for all resources including webhook signature verification and error handling - docs/api/sdk/python.md: Python SDK examples with Flask webhook handler - docs/api/sdk/go.md: Go SDK examples with net/http webhook handler - docs/api/guides/getting-started.md: 7-step first integration walkthrough - docs/api/guides/saas-integration.md: feature gating, upgrade/downgrade, dunning management, idempotent webhook processing, per-merchant themes - docs/api/guides/theme-integration.md: CSS variable injection, contrast audit, export/import, light/dark variant inheritance - docs/api/README.md: versioned documentation hub with version table, rate-limit reference, and environment URLs ## #379 — Custom theme system - src/theme/types.ts: extend BrandConfig with logoUri and font (ThemeFont); add ThemeExport (version-enveloped portable snapshot); add ContrastResult for WCAG audit results - src/theme/cssVariables.ts: new module with generateCssVariables (maps ThemeColors keys to --st-* CSS custom properties, camelCase → kebab-case), toCssBlock (serialise to :root{} string), relativeLuminance, contrastRatio, checkContrast (AA/AAA pass/fail), auditThemeContrast (6 key colour pairs) - src/theme/themes.ts: attach cssVariables to all three built-in themes; createBrandTheme now accepts logoUri and font from BrandConfig and regenerates cssVariables automatically - src/theme/themeStore.ts: add exportTheme (JSON with version envelope, omits cssVariables) and importTheme (validates envelope, regenerates cssVariables, replaces same-id themes); persist hook strips cssVariables to keep AsyncStorage lean and regenerates on rehydration - src/theme/index.ts: export all new utilities and types - src/theme/__tests__/cssVariables.test.ts: new — covers generateCssVariables, toCssBlock, relativeLuminance, contrastRatio, checkContrast, auditThemeContrast - src/theme/__tests__/themeStore.test.ts: extended — covers logoUri/font in addBrandTheme, automatic cssVariables generation, exportTheme round-trip, importTheme (success, invalid JSON, wrong version, dedup) Co-Authored-By: Claude Sonnet 4.6 --- docs/api/README.md | 83 +++ docs/api/guides/getting-started.md | 132 ++++ docs/api/guides/saas-integration.md | 169 ++++++ docs/api/guides/theme-integration.md | 143 +++++ docs/api/openapi.yaml | 738 +++++++++++++++++++++++ docs/api/sdk/go.md | 200 ++++++ docs/api/sdk/javascript.md | 202 +++++++ docs/api/sdk/python.md | 167 +++++ docs/api/swagger.html | 32 + docs/api/webhooks.md | 312 ++++++++++ src/theme/__tests__/cssVariables.test.ts | 119 ++++ src/theme/__tests__/themeStore.test.ts | 85 +++ src/theme/cssVariables.ts | 122 ++++ src/theme/index.ts | 5 +- src/theme/themeStore.ts | 67 +- src/theme/themes.ts | 17 +- src/theme/types.ts | 48 +- 17 files changed, 2629 insertions(+), 12 deletions(-) create mode 100644 docs/api/README.md create mode 100644 docs/api/guides/getting-started.md create mode 100644 docs/api/guides/saas-integration.md create mode 100644 docs/api/guides/theme-integration.md create mode 100644 docs/api/openapi.yaml create mode 100644 docs/api/sdk/go.md create mode 100644 docs/api/sdk/javascript.md create mode 100644 docs/api/sdk/python.md create mode 100644 docs/api/swagger.html create mode 100644 docs/api/webhooks.md create mode 100644 src/theme/__tests__/cssVariables.test.ts create mode 100644 src/theme/cssVariables.ts diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..5d42f2a --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,83 @@ +# SubTrackr API Documentation + +**Current stable version: v1** + +--- + +## Quick links + +| Resource | Description | +|----------|-------------| +| [OpenAPI Spec](./openapi.yaml) | Machine-readable OpenAPI 3.0.3 specification | +| [Interactive Explorer](./swagger.html) | Swagger UI — try every endpoint in-browser | +| [Webhook Reference](./webhooks.md) | All event types, payloads, and retry policy | +| [JS/TS SDK](./sdk/javascript.md) | JavaScript and TypeScript examples | +| [Python SDK](./sdk/python.md) | Python examples | +| [Go SDK](./sdk/go.md) | Go examples | +| [Getting Started](./guides/getting-started.md) | First integration in 7 steps | +| [SaaS Integration](./guides/saas-integration.md) | Feature gating, dunning, upgrade flows | +| [Theme Integration](./guides/theme-integration.md) | White-label brand theming | + +--- + +## API versioning + +| Version | Status | End-of-life | +|---------|--------|-------------| +| `v1` | **Stable** — current | — | +| `v0` | Deprecated | 2025-06-01 | + +Breaking changes are introduced under a new major version with a minimum +6-month deprecation window. Non-breaking additions (new optional fields, new +endpoints) may be added to `v1` at any time. + +Specify the version in the path: + +``` +https://api.subtrackr.io/v1/subscriptions +``` + +--- + +## Authentication + +All endpoints require a Bearer token: + +``` +Authorization: Bearer +``` + +API keys are scoped to a merchant and can be rotated from **Settings → API Keys**. + +--- + +## Rate limits + +| Scope | Limit | +|-------|-------| +| Default per key | 60 requests / minute | +| Burst | 10 requests / second | +| Webhook delivery | 3 concurrent per endpoint | + +Rate-limit headers are returned on every response: +- `X-RateLimit-Limit` +- `X-RateLimit-Remaining` +- `X-RateLimit-Reset` (Unix timestamp) + +--- + +## Environments + +| Environment | Base URL | Purpose | +|-------------|----------|---------| +| Production | `https://api.subtrackr.io/v1` | Live traffic | +| Sandbox | `https://sandbox.subtrackr.io/v1` | Testing — no real charges | + +Sandbox API keys are prefixed with `sk_test_`. + +--- + +## Support + +- API issues: [GitHub Issues](https://github.com/Smartdevs17/SubTrackr/issues) +- Email: api@subtrackr.io diff --git a/docs/api/guides/getting-started.md b/docs/api/guides/getting-started.md new file mode 100644 index 0000000..eeb6402 --- /dev/null +++ b/docs/api/guides/getting-started.md @@ -0,0 +1,132 @@ +# Getting Started + +This guide walks you through your first integration with the SubTrackr API — +from creating a customer to processing a subscription payment. + +## Prerequisites + +- A SubTrackr account (sign up at [app.subtrackr.io](https://app.subtrackr.io)) +- An API key from **Settings → API Keys** +- Node.js 18+ (or Python 3.10+, or Go 1.21+) + +--- + +## Step 1 — Install the SDK + +```bash +npm install @subtrackr/sdk +``` + +## Step 2 — Initialise the client + +```typescript +import { SubTrackr } from '@subtrackr/sdk'; + +const client = new SubTrackr({ + apiKey: process.env.SUBTRACKR_API_KEY!, + baseUrl: 'https://sandbox.subtrackr.io/v1', // use sandbox first +}); +``` + +## Step 3 — Create a plan + +```typescript +const plan = await client.plans.create({ + name: 'Pro Monthly', + price: 29.99, + currency: 'USD', + billingCycle: 'monthly', + trialDays: 14, + features: ['Unlimited projects', 'Priority support'], +}); + +console.log('Plan created:', plan.id); // plan_monthly_pro +``` + +## Step 4 — Create a customer + +```typescript +const customer = await client.customers.create({ + email: 'jane@example.com', + name: 'Jane Doe', +}); + +console.log('Customer created:', customer.id); // cus_xyz789 +``` + +## Step 5 — Subscribe the customer + +```typescript +const subscription = await client.subscriptions.create({ + customerId: customer.id, + planId: plan.id, + trialEnd: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), +}); + +console.log('Subscription created:', subscription.id); +console.log('Status:', subscription.status); // trialing +``` + +## Step 6 — Register a webhook + +```typescript +const endpoint = await client.webhooks.create({ + url: 'https://your-app.com/webhooks/subtrackr', + events: [ + 'subscription.created', + 'subscription.trial_will_end', + 'invoice.paid', + 'invoice.payment_failed', + ], +}); + +// Store this securely — never log or expose it +const signingSecret = endpoint.signingSecret; +``` + +## Step 7 — Handle webhook events + +```typescript +import express from 'express'; +import { verifyWebhookSignature } from '@subtrackr/sdk'; + +const app = express(); + +app.post( + '/webhooks/subtrackr', + express.raw({ type: 'application/json' }), + (req, res) => { + const sig = req.headers['subtrackr-signature'] as string; + const event = verifyWebhookSignature(req.body, sig, signingSecret); + + if (!event) return res.status(400).send('Invalid signature'); + + switch (event.type) { + case 'subscription.trial_will_end': + // Send reminder email to customer + sendTrialEndingEmail(event.data.id); + break; + case 'invoice.paid': + // Unlock features for paid tier + grantAccess(event.data.customerId); + break; + case 'invoice.payment_failed': + // Notify customer to update payment method + sendPaymentFailedEmail(event.data.customerId); + break; + } + + res.json({ received: true }); + } +); + +app.listen(3000); +``` + +--- + +## What's next? + +- [Webhook Event Reference](../webhooks.md) — full event catalogue +- [White-label Themes](./theme-integration.md) — customise the UI for your brand +- [SaaS Integration Guide](./saas-integration.md) — end-to-end SaaS pattern diff --git a/docs/api/guides/saas-integration.md b/docs/api/guides/saas-integration.md new file mode 100644 index 0000000..ce5d4f4 --- /dev/null +++ b/docs/api/guides/saas-integration.md @@ -0,0 +1,169 @@ +# SaaS Integration Guide + +A complete end-to-end pattern for integrating SubTrackr into a SaaS application: +feature gating, upgrade/downgrade flows, and dunning management. + +--- + +## Architecture overview + +``` +User signs up + │ + ▼ +Create Customer (SubTrackr) + │ + ▼ +User selects plan → Create Subscription + │ + ├─ status: trialing ──► trial_will_end webhook ──► prompt payment method + │ + └─ status: active + │ + ├─ invoice.paid ──► grant / maintain access + ├─ invoice.payment_failed ──► past_due ──► dunning emails + ├─ subscription.cancelled ──► revoke access at period end + └─ subscription.updated ──► sync plan to feature flags +``` + +--- + +## Feature gating + +Map each plan to a set of feature flags and check them on every protected +route: + +```typescript +// plans.config.ts +export const PLAN_FEATURES: Record = { + plan_free: ['basic_analytics'], + plan_monthly_pro: ['basic_analytics', 'advanced_analytics', 'api_access'], + plan_enterprise: ['basic_analytics', 'advanced_analytics', 'api_access', 'sso', 'audit_logs'], +}; + +// middleware/requireFeature.ts +import { SubTrackr } from '@subtrackr/sdk'; +import { PLAN_FEATURES } from './plans.config'; + +export function requireFeature(feature: string) { + return async (req: Request, res: Response, next: NextFunction) => { + const sub = await getActiveSubscription(req.user.id); // your DB lookup + const features = PLAN_FEATURES[sub?.planId ?? 'plan_free'] ?? []; + + if (!features.includes(feature)) { + return res.status(403).json({ error: 'upgrade_required', feature }); + } + next(); + }; +} + +// Usage +app.get('/api/analytics/advanced', requireFeature('advanced_analytics'), handler); +``` + +--- + +## Upgrade / downgrade flow + +```typescript +// Upgrade: switch plan immediately +async function upgradePlan(subscriptionId: string, newPlanId: string) { + const updated = await client.subscriptions.update(subscriptionId, { + planId: newPlanId, + }); + // Webhook subscription.updated fires — sync feature flags + return updated; +} + +// Downgrade: switch at period end to avoid surprise charges +async function downgradePlan(subscriptionId: string, newPlanId: string) { + const updated = await client.subscriptions.update(subscriptionId, { + planId: newPlanId, + // apply at next billing cycle + prorationBehavior: 'none', + }); + return updated; +} +``` + +--- + +## Dunning management + +When `invoice.payment_failed` fires, kick off a dunning sequence: + +```typescript +const DUNNING_SEQUENCE = [ + { delayDays: 0, message: 'Your payment failed. Please update your card.' }, + { delayDays: 3, message: 'Reminder: your account will be suspended in 4 days.' }, + { delayDays: 7, message: 'Final notice: update payment to avoid cancellation.' }, +]; + +async function startDunning(customerId: string, invoiceId: string) { + for (const step of DUNNING_SEQUENCE) { + await scheduleEmail({ + to: customer.email, + sendAt: addDays(new Date(), step.delayDays), + body: step.message, + meta: { invoiceId }, + }); + } +} + +// In your webhook handler +case 'invoice.payment_failed': + await startDunning(event.data.customerId, event.data.id); + break; + +// Cancel after final dunning failure +case 'subscription.expired': + await revokeAllAccess(event.data.customerId); + break; +``` + +--- + +## Idempotent webhook processing + +Always use the event `id` as a deduplication key: + +```typescript +import { db } from './database'; + +async function processWebhookEvent(event: WebhookEvent) { + const existing = await db.webhookEvents.findUnique({ where: { id: event.id } }); + if (existing) return; // already processed + + await db.webhookEvents.create({ data: { id: event.id, type: event.type } }); + + // Now safe to process + switch (event.type) { + // ... + } +} +``` + +--- + +## White-label theme per merchant + +```typescript +// On merchant onboarding +const theme = await client.themes.create({ + id: `brand-${merchant.slug}`, + name: merchant.name, + mode: 'dark', + colors: { + primary: merchant.brandColor, + secondary: darken(merchant.brandColor, 20), + accent: merchant.accentColor, + // ... other colors + }, + logoUri: merchant.logoUrl, +}); + +// Store theme.id in your merchant record +await db.merchants.update({ where: { id: merchant.id }, data: { themeId: theme.id } }); +``` + +See [Theme Integration Guide](./theme-integration.md) for the full white-label setup. diff --git a/docs/api/guides/theme-integration.md b/docs/api/guides/theme-integration.md new file mode 100644 index 0000000..ada3730 --- /dev/null +++ b/docs/api/guides/theme-integration.md @@ -0,0 +1,143 @@ +# Theme Integration Guide + +Use SubTrackr's white-label theme system to apply your brand's colours, logo, +and fonts to the subscription management UI. + +--- + +## Concepts + +| Term | Description | +|------|-------------| +| **Built-in theme** | `dark`, `light`, `high-contrast` — shipped with SubTrackr | +| **Brand theme** | A custom theme you create by overriding colours/logo/font | +| **CSS variables** | Auto-generated `--st-*` properties derived from theme colours | +| **Theme export** | A portable JSON snapshot of a theme (version-enveloped) | + +--- + +## Create a brand theme via API + +```typescript +const theme = await client.themes.create({ + id: 'brand-acme', + name: 'Acme Corp', + mode: 'dark', + colors: { + primary: '#ff6b35', + secondary: '#004e89', + accent: '#1a936f', + success: '#10b981', + warning: '#f59e0b', + error: '#ef4444', + background: '#0f172a', + surface: '#1e293b', + text: '#f8fafc', + textSecondary: '#cbd5e1', + border: '#334155', + overlay: 'rgba(15, 23, 42, 0.8)', + }, + logoUri: 'https://cdn.acme.com/logo-white.png', + font: { family: 'Inter', scale: 1.0 }, +}); + +console.log(theme.cssVariables?.['--st-primary']); // '#ff6b35' +``` + +--- + +## Use generated CSS variables in a web view + +The API returns a `cssVariables` map on every theme. Inject it into a `...` }} +/> +``` + +--- + +## Accessibility contrast check + +Before shipping a brand theme, run the built-in contrast audit: + +```typescript +import { auditThemeContrast } from '@subtrackr/sdk/theme'; + +const audit = auditThemeContrast(theme); + +for (const [pair, result] of Object.entries(audit)) { + if (!result.passesAA) { + console.warn(`⚠️ ${pair}: ratio ${result.ratio} — fails WCAG AA`); + } +} +// Example output: +// ⚠️ primary/background: ratio 3.1 — fails WCAG AA +``` + +Fix failing pairs by adjusting the colour until the ratio is ≥ 4.5 (AA) or +≥ 7.0 (AAA). + +--- + +## Export and import themes + +Export for sharing or version control: + +```typescript +// In-app (React Native) +import { useThemeStore } from '@/theme'; + +const json = useThemeStore.getState().exportTheme('brand-acme'); +// Share json string via email, clipboard, or API +``` + +Import on another device or tenant: + +```typescript +const id = useThemeStore.getState().importTheme(json); +if (id) { + useThemeStore.getState().setTheme(id); +} +``` + +--- + +## Theme inheritance (light / dark variants) + +Create paired themes that inherit from their respective base: + +```typescript +import { createBrandTheme } from '@/theme'; +import { darkTheme, lightTheme } from '@/theme'; + +const brand = { primary: '#ff6b35', secondary: '#004e89', accent: '#1a936f' }; + +const acmeDark = createBrandTheme(darkTheme, brand, 'acme-dark', 'Acme Dark'); +const acmeLight = createBrandTheme(lightTheme, brand, 'acme-light', 'Acme Light'); + +useThemeStore.getState().addBrandTheme(brand, acmeDark.id, acmeDark.name); +``` + +The `toggleMode` action automatically switches between the base dark and light +themes. For brand variants, implement your own toggle: + +```typescript +function toggleAcmeMode() { + const current = useThemeStore.getState().theme; + const next = current.id === 'acme-dark' ? 'acme-light' : 'acme-dark'; + useThemeStore.getState().setTheme(next); +} +``` diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml new file mode 100644 index 0000000..e3b265e --- /dev/null +++ b/docs/api/openapi.yaml @@ -0,0 +1,738 @@ +openapi: 3.0.3 +info: + title: SubTrackr Subscription Management API + version: "1.0.0" + description: | + The SubTrackr API enables merchants to create and manage subscriptions, + process payments, configure webhooks, and customise the white-label + subscription UI for their customers. + + ## Authentication + All endpoints require a Bearer token in the `Authorization` header. + + ## Versioning + The current stable version is `v1`. Include `/v1/` in every path. + Breaking changes will be introduced under `/v2/` with a 6-month deprecation window. + + ## Rate limiting + Default: **60 requests / minute** per merchant API key. + Burst allowance: **10 requests / second**. + Rate-limit headers are returned on every response: + - `X-RateLimit-Limit` + - `X-RateLimit-Remaining` + - `X-RateLimit-Reset` (Unix timestamp) + contact: + name: SubTrackr Developer Support + email: api@subtrackr.io + license: + name: MIT + url: https://opensource.org/licenses/MIT + +servers: + - url: https://api.subtrackr.io/v1 + description: Production + - url: https://sandbox.subtrackr.io/v1 + description: Sandbox (test environment, no real charges) + +tags: + - name: Subscriptions + description: Create, read, update, cancel, and resume subscriptions + - name: Plans + description: Subscription plan catalogue management + - name: Billing + description: Invoices, charges, and payment methods + - name: Customers + description: Customer profile management + - name: Webhooks + description: Webhook endpoint registration and delivery logs + - name: Themes + description: White-label UI theme management + +# --------------------------------------------------------------------------- +# Security +# --------------------------------------------------------------------------- +security: + - bearerAuth: [] + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + # ------------------------------------------------------------------------- + # Reusable schemas + # ------------------------------------------------------------------------- + schemas: + + Error: + type: object + required: [code, message] + properties: + code: + type: string + example: subscription_not_found + message: + type: string + example: No subscription with id sub_abc123 was found. + details: + type: object + additionalProperties: true + + PaginatedMeta: + type: object + properties: + total: { type: integer, example: 142 } + page: { type: integer, example: 1 } + limit: { type: integer, example: 20 } + hasNext: { type: boolean, example: true } + hasPrev: { type: boolean, example: false } + + SubscriptionStatus: + type: string + enum: [active, paused, cancelled, past_due, trialing, expired] + + BillingCycle: + type: string + enum: [monthly, yearly, weekly, custom] + + Plan: + type: object + required: [id, name, price, currency, billingCycle] + properties: + id: { type: string, example: plan_monthly_pro } + name: { type: string, example: Pro Monthly } + description: { type: string, example: Full access with monthly billing } + price: + type: number + format: float + example: 29.99 + currency: { type: string, example: USD } + billingCycle: { $ref: '#/components/schemas/BillingCycle' } + trialDays: { type: integer, example: 14, nullable: true } + features: + type: array + items: { type: string } + example: ["Unlimited projects", "Priority support", "Analytics"] + metadata: + type: object + additionalProperties: true + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + Subscription: + type: object + required: [id, customerId, planId, status, currentPeriodStart, currentPeriodEnd] + properties: + id: { type: string, example: sub_abc123 } + customerId: { type: string, example: cus_xyz789 } + planId: { type: string, example: plan_monthly_pro } + status: { $ref: '#/components/schemas/SubscriptionStatus' } + currentPeriodStart: { type: string, format: date-time } + currentPeriodEnd: { type: string, format: date-time } + cancelAtPeriodEnd: { type: boolean, example: false } + cancelledAt: { type: string, format: date-time, nullable: true } + pausedAt: { type: string, format: date-time, nullable: true } + resumeAt: { type: string, format: date-time, nullable: true } + trialEnd: { type: string, format: date-time, nullable: true } + metadata: + type: object + additionalProperties: true + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + Customer: + type: object + required: [id, email] + properties: + id: { type: string, example: cus_xyz789 } + email: { type: string, format: email, example: jane@example.com } + name: { type: string, example: Jane Doe } + metadata: + type: object + additionalProperties: true + createdAt: { type: string, format: date-time } + + Invoice: + type: object + properties: + id: { type: string, example: inv_001 } + subscriptionId: { type: string, example: sub_abc123 } + customerId: { type: string, example: cus_xyz789 } + amount: { type: number, format: float, example: 29.99 } + currency: { type: string, example: USD } + status: { type: string, enum: [draft, open, paid, void, uncollectible] } + dueDate: { type: string, format: date-time } + paidAt: { type: string, format: date-time, nullable: true } + lineItems: + type: array + items: + type: object + properties: + description: { type: string } + amount: { type: number } + quantity: { type: integer } + + WebhookEndpoint: + type: object + required: [id, url, events] + properties: + id: { type: string, example: whe_001 } + url: { type: string, format: uri, example: https://example.com/webhooks/subtrackr } + events: + type: array + items: { type: string } + example: ["subscription.created", "subscription.cancelled", "invoice.paid"] + active: { type: boolean, example: true } + signingSecret: + type: string + description: Returned only on creation. Store it securely. + example: whsec_abcdefgh1234 + createdAt: { type: string, format: date-time } + + ThemeColors: + type: object + required: [primary, secondary, accent, background, surface, text] + properties: + primary: { type: string, pattern: '^#[0-9a-fA-F]{6}$', example: '#6366f1' } + secondary: { type: string, pattern: '^#[0-9a-fA-F]{6}$', example: '#8b5cf6' } + accent: { type: string, pattern: '^#[0-9a-fA-F]{6}$', example: '#06b6d4' } + success: { type: string, example: '#10b981' } + warning: { type: string, example: '#f59e0b' } + error: { type: string, example: '#ef4444' } + background: { type: string, example: '#0f172a' } + surface: { type: string, example: '#1e293b' } + text: { type: string, example: '#f8fafc' } + textSecondary: { type: string, example: '#cbd5e1' } + border: { type: string, example: '#334155' } + overlay: { type: string, example: 'rgba(15, 23, 42, 0.8)' } + + Theme: + type: object + required: [id, name, mode, colors] + properties: + id: { type: string, example: brand-acme } + name: { type: string, example: Acme Corp Theme } + mode: { type: string, enum: [light, dark] } + colors: { $ref: '#/components/schemas/ThemeColors' } + logoUri: { type: string, format: uri, nullable: true } + font: + type: object + properties: + family: { type: string, example: Inter } + scale: { type: number, example: 1.0 } + cssVariables: + type: object + additionalProperties: { type: string } + readOnly: true + description: Generated CSS custom properties. Read-only; computed by the server. + + # ------------------------------------------------------------------------- + # Reusable responses + # ------------------------------------------------------------------------- + responses: + Unauthorized: + description: Missing or invalid Bearer token + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + NotFound: + description: Resource not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + UnprocessableEntity: + description: Validation error + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + TooManyRequests: + description: Rate limit exceeded + headers: + Retry-After: { schema: { type: integer } } + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +paths: + + # ---- Subscriptions ------------------------------------------------------- + + /subscriptions: + get: + tags: [Subscriptions] + summary: List subscriptions + description: Returns a paginated list of subscriptions, optionally filtered by status or customer. + parameters: + - name: status + in: query + schema: { $ref: '#/components/schemas/SubscriptionStatus' } + - name: customerId + in: query + schema: { type: string } + - name: page + in: query + schema: { type: integer, default: 1 } + - name: limit + in: query + schema: { type: integer, default: 20, maximum: 100 } + responses: + '200': + description: OK + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginatedMeta' + - type: object + properties: + data: + type: array + items: { $ref: '#/components/schemas/Subscription' } + '401': { $ref: '#/components/responses/Unauthorized' } + '429': { $ref: '#/components/responses/TooManyRequests' } + + post: + tags: [Subscriptions] + summary: Create a subscription + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [customerId, planId] + properties: + customerId: { type: string } + planId: { type: string } + trialEnd: { type: string, format: date-time, nullable: true } + cancelAtPeriodEnd: { type: boolean, default: false } + metadata: + type: object + additionalProperties: true + example: + customerId: cus_xyz789 + planId: plan_monthly_pro + trialEnd: "2025-03-01T00:00:00Z" + responses: + '201': + description: Subscription created + content: + application/json: + schema: { $ref: '#/components/schemas/Subscription' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/UnprocessableEntity' } + + /subscriptions/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string } + + get: + tags: [Subscriptions] + summary: Retrieve a subscription + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Subscription' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + patch: + tags: [Subscriptions] + summary: Update a subscription + description: Supports plan upgrades/downgrades, metadata updates, and trial extension. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + planId: { type: string } + cancelAtPeriodEnd: { type: boolean } + trialEnd: { type: string, format: date-time } + metadata: + type: object + additionalProperties: true + responses: + '200': + description: Updated subscription + content: + application/json: + schema: { $ref: '#/components/schemas/Subscription' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /subscriptions/{id}/cancel: + post: + tags: [Subscriptions] + summary: Cancel a subscription + parameters: + - name: id + in: path + required: true + schema: { type: string } + requestBody: + content: + application/json: + schema: + type: object + properties: + immediately: + type: boolean + default: false + description: Cancel immediately rather than at period end. + reason: { type: string } + responses: + '200': + description: Subscription cancelled + content: + application/json: + schema: { $ref: '#/components/schemas/Subscription' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /subscriptions/{id}/pause: + post: + tags: [Subscriptions] + summary: Pause a subscription + parameters: + - name: id + in: path + required: true + schema: { type: string } + requestBody: + content: + application/json: + schema: + type: object + properties: + resumeAt: + type: string + format: date-time + description: Auto-resume at this timestamp. Omit to pause indefinitely. + responses: + '200': + description: Subscription paused + content: + application/json: + schema: { $ref: '#/components/schemas/Subscription' } + + /subscriptions/{id}/resume: + post: + tags: [Subscriptions] + summary: Resume a paused subscription + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: Subscription resumed + content: + application/json: + schema: { $ref: '#/components/schemas/Subscription' } + + # ---- Plans --------------------------------------------------------------- + + /plans: + get: + tags: [Plans] + summary: List plans + parameters: + - name: active + in: query + schema: { type: boolean } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: '#/components/schemas/Plan' } + + post: + tags: [Plans] + summary: Create a plan + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, price, currency, billingCycle] + properties: + name: { type: string } + description: { type: string } + price: { type: number } + currency: { type: string } + billingCycle: { $ref: '#/components/schemas/BillingCycle' } + trialDays: { type: integer } + features: + type: array + items: { type: string } + responses: + '201': + description: Plan created + content: + application/json: + schema: { $ref: '#/components/schemas/Plan' } + + /plans/{id}: + get: + tags: [Plans] + summary: Retrieve a plan + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Plan' } + '404': { $ref: '#/components/responses/NotFound' } + + # ---- Customers ----------------------------------------------------------- + + /customers: + post: + tags: [Customers] + summary: Create a customer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + name: { type: string } + metadata: { type: object, additionalProperties: true } + responses: + '201': + description: Customer created + content: + application/json: + schema: { $ref: '#/components/schemas/Customer' } + + /customers/{id}: + get: + tags: [Customers] + summary: Retrieve a customer + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Customer' } + '404': { $ref: '#/components/responses/NotFound' } + + # ---- Billing / Invoices -------------------------------------------------- + + /invoices: + get: + tags: [Billing] + summary: List invoices + parameters: + - name: subscriptionId + in: query + schema: { type: string } + - name: status + in: query + schema: { type: string, enum: [draft, open, paid, void, uncollectible] } + - name: page + in: query + schema: { type: integer, default: 1 } + - name: limit + in: query + schema: { type: integer, default: 20 } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: '#/components/schemas/Invoice' } + + /invoices/{id}: + get: + tags: [Billing] + summary: Retrieve an invoice + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Invoice' } + '404': { $ref: '#/components/responses/NotFound' } + + # ---- Webhooks ------------------------------------------------------------ + + /webhooks: + get: + tags: [Webhooks] + summary: List webhook endpoints + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: '#/components/schemas/WebhookEndpoint' } + + post: + tags: [Webhooks] + summary: Register a webhook endpoint + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [url, events] + properties: + url: + type: string + format: uri + events: + type: array + items: { type: string } + example: ["subscription.created", "invoice.paid"] + responses: + '201': + description: Webhook endpoint created (signingSecret returned only here) + content: + application/json: + schema: { $ref: '#/components/schemas/WebhookEndpoint' } + + /webhooks/{id}: + delete: + tags: [Webhooks] + summary: Delete a webhook endpoint + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '204': + description: Deleted + '404': { $ref: '#/components/responses/NotFound' } + + # ---- Themes -------------------------------------------------------------- + + /themes: + get: + tags: [Themes] + summary: List merchant themes + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: '#/components/schemas/Theme' } + + post: + tags: [Themes] + summary: Create or replace a theme + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/Theme' } + example: + id: brand-acme + name: Acme Corp + mode: dark + colors: + primary: '#ff6b35' + secondary: '#004e89' + accent: '#1a936f' + success: '#10b981' + warning: '#f59e0b' + error: '#ef4444' + background: '#0f172a' + surface: '#1e293b' + text: '#f8fafc' + textSecondary: '#cbd5e1' + border: '#334155' + overlay: 'rgba(15, 23, 42, 0.8)' + logoUri: 'https://cdn.acme.com/logo.png' + responses: + '201': + description: Theme created + content: + application/json: + schema: { $ref: '#/components/schemas/Theme' } + '422': { $ref: '#/components/responses/UnprocessableEntity' } + + /themes/{id}: + get: + tags: [Themes] + summary: Retrieve a theme + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Theme' } + '404': { $ref: '#/components/responses/NotFound' } + + delete: + tags: [Themes] + summary: Delete a theme + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '204': + description: Deleted + '404': { $ref: '#/components/responses/NotFound' } diff --git a/docs/api/sdk/go.md b/docs/api/sdk/go.md new file mode 100644 index 0000000..ad3d60a --- /dev/null +++ b/docs/api/sdk/go.md @@ -0,0 +1,200 @@ +# Go SDK Examples + +## Installation + +```bash +go get github.com/subtrackr/subtrackr-go +``` + +## Initialisation + +```go +package main + +import ( + "os" + "github.com/subtrackr/subtrackr-go" +) + +func main() { + client := subtrackr.New(subtrackr.Config{ + APIKey: os.Getenv("SUBTRACKR_API_KEY"), + // optional: use sandbox for testing + BaseURL: "https://sandbox.subtrackr.io/v1", + }) +} +``` + +--- + +## Subscriptions + +### Create a subscription + +```go +import ( + "context" + "time" + "github.com/subtrackr/subtrackr-go" +) + +trialEnd := time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC) + +sub, err := client.Subscriptions.Create(context.Background(), subtrackr.CreateSubscriptionParams{ + CustomerID: "cus_xyz789", + PlanID: "plan_monthly_pro", + TrialEnd: &trialEnd, +}) +if err != nil { + log.Fatal(err) +} + +fmt.Println(sub.ID) // sub_abc123 +fmt.Println(sub.Status) // trialing +``` + +### List subscriptions + +```go +page, err := client.Subscriptions.List(context.Background(), subtrackr.ListSubscriptionsParams{ + Status: subtrackr.StatusActive, + Page: 1, + Limit: 20, +}) +if err != nil { + log.Fatal(err) +} + +for _, s := range page.Data { + fmt.Printf("%s — %s\n", s.ID, s.Status) +} +``` + +### Cancel a subscription + +```go +// Cancel at period end +cancelled, err := client.Subscriptions.Cancel(context.Background(), "sub_abc123", + subtrackr.CancelParams{ + Immediately: false, + Reason: "Customer requested", + }) + +// Cancel immediately +cancelled, err := client.Subscriptions.Cancel(context.Background(), "sub_abc123", + subtrackr.CancelParams{Immediately: true}) +``` + +### Pause and resume + +```go +resumeAt := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) + +_, err = client.Subscriptions.Pause(context.Background(), "sub_abc123", + subtrackr.PauseParams{ResumeAt: &resumeAt}) + +_, err = client.Subscriptions.Resume(context.Background(), "sub_abc123") +``` + +--- + +## Plans + +```go +plan, err := client.Plans.Create(context.Background(), subtrackr.CreatePlanParams{ + Name: "Pro Monthly", + Price: 29.99, + Currency: "USD", + BillingCycle: subtrackr.BillingCycleMonthly, + TrialDays: 14, + Features: []string{"Unlimited projects", "Priority support"}, +}) + +plans, err := client.Plans.List(context.Background(), subtrackr.ListPlansParams{Active: true}) +``` + +--- + +## Customers + +```go +customer, err := client.Customers.Create(context.Background(), subtrackr.CreateCustomerParams{ + Email: "jane@example.com", + Name: "Jane Doe", + Metadata: map[string]interface{}{"externalId": "user_12345"}, +}) + +retrieved, err := client.Customers.Get(context.Background(), customer.ID) +``` + +--- + +## Webhooks + +```go +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" +) + +endpoint, err := client.Webhooks.Create(context.Background(), subtrackr.CreateWebhookParams{ + URL: "https://example.com/webhooks/subtrackr", + Events: []string{ + "subscription.created", + "subscription.cancelled", + "invoice.paid", + }, +}) +// Store endpoint.SigningSecret securely — only returned on creation +signingSecret := endpoint.SigningSecret + +// HTTP handler to verify webhook signatures +func WebhookHandler(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + sig := r.Header.Get("Subtrackr-Signature") + + mac := hmac.New(sha256.New, []byte(signingSecret)) + mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + + if !hmac.Equal([]byte(expected), []byte(sig)) { + http.Error(w, "Invalid signature", http.StatusBadRequest) + return + } + + var event subtrackr.WebhookEvent + if err := json.NewDecoder(bytes.NewReader(body)).Decode(&event); err != nil { + http.Error(w, "Bad payload", http.StatusBadRequest) + return + } + + switch event.Type { + case "subscription.created": + log.Printf("New subscription: %s", event.Data["id"]) + case "invoice.paid": + log.Printf("Invoice paid: %v", event.Data["amount"]) + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]bool{"received": true}) +} +``` + +--- + +## Error handling + +```go +import "github.com/subtrackr/subtrackr-go/errors" + +_, err := client.Subscriptions.Get(context.Background(), "sub_does_not_exist") +if err != nil { + if apiErr, ok := err.(*errors.APIError); ok { + fmt.Println(apiErr.Code) // subscription_not_found + fmt.Println(apiErr.Message) // No subscription with id ... + fmt.Println(apiErr.Status) // 404 + } +} +``` diff --git a/docs/api/sdk/javascript.md b/docs/api/sdk/javascript.md new file mode 100644 index 0000000..dc6dd83 --- /dev/null +++ b/docs/api/sdk/javascript.md @@ -0,0 +1,202 @@ +# JavaScript / TypeScript SDK Examples + +## Installation + +```bash +npm install @subtrackr/sdk +# or +yarn add @subtrackr/sdk +``` + +## Initialisation + +```typescript +import { SubTrackr } from '@subtrackr/sdk'; + +const client = new SubTrackr({ + apiKey: process.env.SUBTRACKR_API_KEY!, + // optional: use sandbox for testing + baseUrl: 'https://sandbox.subtrackr.io/v1', +}); +``` + +--- + +## Subscriptions + +### Create a subscription + +```typescript +const subscription = await client.subscriptions.create({ + customerId: 'cus_xyz789', + planId: 'plan_monthly_pro', + trialEnd: new Date('2025-03-01'), +}); + +console.log(subscription.id); // sub_abc123 +console.log(subscription.status); // 'trialing' +``` + +### List subscriptions (paginated) + +```typescript +const page = await client.subscriptions.list({ + status: 'active', + page: 1, + limit: 20, +}); + +for (const sub of page.data) { + console.log(`${sub.id} — ${sub.status}`); +} + +if (page.hasNext) { + const next = await client.subscriptions.list({ page: 2, limit: 20 }); +} +``` + +### Cancel at period end + +```typescript +const cancelled = await client.subscriptions.cancel('sub_abc123', { + immediately: false, + reason: 'Customer requested', +}); +// cancelled.cancelAtPeriodEnd === true +``` + +### Cancel immediately + +```typescript +await client.subscriptions.cancel('sub_abc123', { immediately: true }); +``` + +### Pause and resume + +```typescript +// Pause until a specific date +await client.subscriptions.pause('sub_abc123', { + resumeAt: new Date('2025-06-01'), +}); + +// Resume manually +await client.subscriptions.resume('sub_abc123'); +``` + +--- + +## Plans + +```typescript +// Create a plan +const plan = await client.plans.create({ + name: 'Pro Monthly', + price: 29.99, + currency: 'USD', + billingCycle: 'monthly', + trialDays: 14, + features: ['Unlimited projects', 'Priority support'], +}); + +// List all active plans +const plans = await client.plans.list({ active: true }); +``` + +--- + +## Customers + +```typescript +const customer = await client.customers.create({ + email: 'jane@example.com', + name: 'Jane Doe', + metadata: { externalId: 'user_12345' }, +}); + +const retrieved = await client.customers.get(customer.id); +``` + +--- + +## Webhooks + +```typescript +// Register an endpoint +const endpoint = await client.webhooks.create({ + url: 'https://example.com/webhooks/subtrackr', + events: ['subscription.created', 'subscription.cancelled', 'invoice.paid'], +}); + +// IMPORTANT: store the signingSecret securely — it is only shown once +const { signingSecret } = endpoint; + +// Verify an incoming webhook (e.g. in an Express handler) +import { verifyWebhookSignature } from '@subtrackr/sdk'; + +app.post('/webhooks/subtrackr', express.raw({ type: 'application/json' }), (req, res) => { + const sig = req.headers['subtrackr-signature'] as string; + const event = verifyWebhookSignature(req.body, sig, signingSecret); + if (!event) return res.status(400).send('Invalid signature'); + + switch (event.type) { + case 'subscription.created': + console.log('New subscription:', event.data.id); + break; + case 'invoice.paid': + console.log('Invoice paid:', event.data.id, event.data.amount); + break; + } + + res.json({ received: true }); +}); +``` + +--- + +## Themes + +```typescript +// Create a brand theme +const theme = await client.themes.create({ + id: 'brand-acme', + name: 'Acme Corp', + mode: 'dark', + colors: { + primary: '#ff6b35', + secondary: '#004e89', + accent: '#1a936f', + success: '#10b981', + warning: '#f59e0b', + error: '#ef4444', + background: '#0f172a', + surface: '#1e293b', + text: '#f8fafc', + textSecondary: '#cbd5e1', + border: '#334155', + overlay: 'rgba(15, 23, 42, 0.8)', + }, + logoUri: 'https://cdn.acme.com/logo.png', + font: { family: 'Inter', scale: 1.0 }, +}); + +// The response includes generated CSS variables +console.log(theme.cssVariables?.['--st-primary']); // '#ff6b35' +``` + +--- + +## Error handling + +```typescript +import { SubTrackrError } from '@subtrackr/sdk'; + +try { + await client.subscriptions.get('sub_does_not_exist'); +} catch (err) { + if (err instanceof SubTrackrError) { + console.error(err.code); // 'subscription_not_found' + console.error(err.message); // 'No subscription with id ...' + console.error(err.status); // 404 + } +} +``` diff --git a/docs/api/sdk/python.md b/docs/api/sdk/python.md new file mode 100644 index 0000000..e632654 --- /dev/null +++ b/docs/api/sdk/python.md @@ -0,0 +1,167 @@ +# Python SDK Examples + +## Installation + +```bash +pip install subtrackr-sdk +``` + +## Initialisation + +```python +import os +from subtrackr import SubTrackr + +client = SubTrackr( + api_key=os.environ["SUBTRACKR_API_KEY"], + # optional: use sandbox for testing + base_url="https://sandbox.subtrackr.io/v1", +) +``` + +--- + +## Subscriptions + +### Create a subscription + +```python +from datetime import datetime, timezone + +subscription = client.subscriptions.create( + customer_id="cus_xyz789", + plan_id="plan_monthly_pro", + trial_end=datetime(2025, 3, 1, tzinfo=timezone.utc), +) + +print(subscription.id) # sub_abc123 +print(subscription.status) # trialing +``` + +### List subscriptions + +```python +page = client.subscriptions.list(status="active", page=1, limit=20) + +for sub in page.data: + print(f"{sub.id} — {sub.status}") + +# Auto-paginate all active subscriptions +for sub in client.subscriptions.iter_all(status="active"): + print(sub.id) +``` + +### Cancel a subscription + +```python +# Cancel at period end (default) +client.subscriptions.cancel("sub_abc123", reason="Customer requested") + +# Cancel immediately +client.subscriptions.cancel("sub_abc123", immediately=True) +``` + +### Pause and resume + +```python +from datetime import datetime, timezone + +client.subscriptions.pause( + "sub_abc123", + resume_at=datetime(2025, 6, 1, tzinfo=timezone.utc), +) + +client.subscriptions.resume("sub_abc123") +``` + +--- + +## Plans + +```python +plan = client.plans.create( + name="Pro Monthly", + price=29.99, + currency="USD", + billing_cycle="monthly", + trial_days=14, + features=["Unlimited projects", "Priority support"], +) + +plans = client.plans.list(active=True) +``` + +--- + +## Customers + +```python +customer = client.customers.create( + email="jane@example.com", + name="Jane Doe", + metadata={"external_id": "user_12345"}, +) + +retrieved = client.customers.get(customer.id) +``` + +--- + +## Webhooks + +```python +import hmac, hashlib + +endpoint = client.webhooks.create( + url="https://example.com/webhooks/subtrackr", + events=["subscription.created", "subscription.cancelled", "invoice.paid"], +) + +# Store endpoint.signing_secret securely — only returned on creation +SIGNING_SECRET = endpoint.signing_secret + +def verify_webhook(payload: bytes, signature: str) -> dict | None: + """Verify and parse an incoming webhook payload.""" + expected = hmac.new( + SIGNING_SECRET.encode(), payload, hashlib.sha256 + ).hexdigest() + if not hmac.compare_digest(expected, signature): + return None + import json + return json.loads(payload) + +# Flask example +from flask import Flask, request, abort +app = Flask(__name__) + +@app.post("/webhooks/subtrackr") +def handle_webhook(): + sig = request.headers.get("Subtrackr-Signature", "") + event = verify_webhook(request.get_data(), sig) + if event is None: + abort(400) + + if event["type"] == "subscription.created": + print("New subscription:", event["data"]["id"]) + elif event["type"] == "invoice.paid": + print("Invoice paid:", event["data"]["amount"]) + + return {"received": True} +``` + +--- + +## Error handling + +```python +from subtrackr.exceptions import SubTrackrError, NotFoundError + +try: + client.subscriptions.get("sub_does_not_exist") +except NotFoundError as e: + print(e.code) # subscription_not_found + print(e.message) # No subscription with id ... + print(e.status) # 404 +except SubTrackrError as e: + print("API error:", e) +``` diff --git a/docs/api/swagger.html b/docs/api/swagger.html new file mode 100644 index 0000000..f286f4d --- /dev/null +++ b/docs/api/swagger.html @@ -0,0 +1,32 @@ + + + + + + SubTrackr API Explorer + + + + +
+ + + + + diff --git a/docs/api/webhooks.md b/docs/api/webhooks.md new file mode 100644 index 0000000..b6c9ae4 --- /dev/null +++ b/docs/api/webhooks.md @@ -0,0 +1,312 @@ +# Webhook Event Reference + +SubTrackr sends HTTP POST requests to your registered endpoint whenever a +billing event occurs. Every request includes a `Subtrackr-Signature` header +you should verify before processing the payload. + +--- + +## Payload envelope + +Every webhook payload follows this structure: + +```json +{ + "id": "evt_abc123", + "type": "subscription.created", + "apiVersion": "2024-01-01", + "createdAt": "2025-01-15T10:30:00Z", + "data": { ... } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Unique event ID. Use this for idempotency. | +| `type` | string | Event type (see table below). | +| `apiVersion` | string | API version that generated this event. | +| `createdAt` | ISO 8601 | When the event occurred. | +| `data` | object | Event-specific payload (see each event below). | + +--- + +## Signature verification + +SubTrackr signs every webhook using HMAC-SHA256. + +``` +Subtrackr-Signature: sha256= +``` + +**Verification steps:** + +1. Extract the raw request body (do NOT parse JSON first). +2. Compute `HMAC-SHA256(body, signingSecret)`. +3. Compare with the value after `sha256=` using a constant-time comparison. +4. Reject requests where the signature does not match. + +--- + +## Retries + +Failed deliveries (non-2xx response, timeout > 30 s) are retried with +exponential back-off: + +| Attempt | Delay | +|---------|-------| +| 1st retry | 5 minutes | +| 2nd retry | 30 minutes | +| 3rd retry | 2 hours | +| 4th retry | 5 hours | +| 5th retry | 10 hours | + +After 5 failed attempts the event is marked `failed` and no further retries occur. +Check the dashboard under **Webhooks → Delivery Logs** to inspect failures. + +--- + +## Event catalogue + +### Subscription events + +#### `subscription.created` + +Fired when a new subscription is created (including trials). + +```json +{ + "id": "evt_001", + "type": "subscription.created", + "apiVersion": "2024-01-01", + "createdAt": "2025-01-15T10:30:00Z", + "data": { + "id": "sub_abc123", + "customerId": "cus_xyz789", + "planId": "plan_monthly_pro", + "status": "trialing", + "currentPeriodStart": "2025-01-15T10:30:00Z", + "currentPeriodEnd": "2025-02-15T10:30:00Z", + "trialEnd": "2025-03-01T00:00:00Z", + "cancelAtPeriodEnd": false + } +} +``` + +--- + +#### `subscription.updated` + +Fired when a subscription's plan, status, or metadata changes. + +```json +{ + "type": "subscription.updated", + "data": { + "id": "sub_abc123", + "previousPlanId": "plan_monthly_basic", + "planId": "plan_monthly_pro", + "status": "active" + } +} +``` + +--- + +#### `subscription.cancelled` + +Fired when a subscription is cancelled (immediately or scheduled). + +```json +{ + "type": "subscription.cancelled", + "data": { + "id": "sub_abc123", + "customerId": "cus_xyz789", + "cancelledAt": "2025-01-20T14:00:00Z", + "cancelAtPeriodEnd": false, + "reason": "Customer requested" + } +} +``` + +--- + +#### `subscription.paused` + +Fired when a subscription is paused. + +```json +{ + "type": "subscription.paused", + "data": { + "id": "sub_abc123", + "pausedAt": "2025-01-20T14:00:00Z", + "resumeAt": "2025-06-01T00:00:00Z" + } +} +``` + +--- + +#### `subscription.resumed` + +Fired when a paused subscription is resumed. + +```json +{ + "type": "subscription.resumed", + "data": { + "id": "sub_abc123", + "resumedAt": "2025-06-01T00:00:00Z", + "status": "active" + } +} +``` + +--- + +#### `subscription.trial_will_end` + +Sent **3 days before** a trial period ends. Use this to prompt the customer to +add a payment method. + +```json +{ + "type": "subscription.trial_will_end", + "data": { + "id": "sub_abc123", + "trialEnd": "2025-03-01T00:00:00Z" + } +} +``` + +--- + +#### `subscription.expired` + +Fired when a subscription reaches its `currentPeriodEnd` without renewal. + +```json +{ + "type": "subscription.expired", + "data": { + "id": "sub_abc123", + "expiredAt": "2025-02-15T10:30:00Z" + } +} +``` + +--- + +### Invoice events + +#### `invoice.created` + +Fired when a new invoice is drafted at the start of a billing cycle. + +```json +{ + "type": "invoice.created", + "data": { + "id": "inv_001", + "subscriptionId": "sub_abc123", + "customerId": "cus_xyz789", + "amount": 29.99, + "currency": "USD", + "status": "open", + "dueDate": "2025-02-15T10:30:00Z" + } +} +``` + +--- + +#### `invoice.paid` + +Fired when a charge succeeds. + +```json +{ + "type": "invoice.paid", + "data": { + "id": "inv_001", + "subscriptionId": "sub_abc123", + "amount": 29.99, + "currency": "USD", + "status": "paid", + "paidAt": "2025-02-15T10:35:00Z" + } +} +``` + +--- + +#### `invoice.payment_failed` + +Fired when a charge attempt fails. The subscription enters `past_due`. + +```json +{ + "type": "invoice.payment_failed", + "data": { + "id": "inv_001", + "subscriptionId": "sub_abc123", + "amount": 29.99, + "currency": "USD", + "failureCode": "card_declined", + "failureMessage": "Your card was declined.", + "nextAttemptAt": "2025-02-18T10:35:00Z" + } +} +``` + +--- + +### Customer events + +#### `customer.created` + +```json +{ + "type": "customer.created", + "data": { + "id": "cus_xyz789", + "email": "jane@example.com", + "name": "Jane Doe" + } +} +``` + +--- + +## Idempotency + +Your handler should be idempotent. SubTrackr may deliver the same event more +than once (e.g. after a network failure). Use `event.id` as a deduplication key: + +```typescript +// Example with a Set in memory (use a DB in production) +const processed = new Set(); + +function handleWebhook(event: WebhookEvent) { + if (processed.has(event.id)) return; // already handled + processed.add(event.id); + // ... process event +} +``` + +--- + +## Testing webhooks locally + +Use the SubTrackr CLI to forward events to your local server: + +```bash +npx subtrackr webhook-forward --url http://localhost:3000/webhooks/subtrackr +``` + +Or replay a specific event from the dashboard: + +```bash +npx subtrackr webhook-replay evt_abc123 --endpoint whe_001 +``` diff --git a/src/theme/__tests__/cssVariables.test.ts b/src/theme/__tests__/cssVariables.test.ts new file mode 100644 index 0000000..5662b1f --- /dev/null +++ b/src/theme/__tests__/cssVariables.test.ts @@ -0,0 +1,119 @@ +import { + generateCssVariables, + toCssBlock, + checkContrast, + auditThemeContrast, + relativeLuminance, + contrastRatio, +} from '../cssVariables'; +import { darkTheme, lightTheme } from '../themes'; + +describe('generateCssVariables', () => { + it('maps every ThemeColors key to a --st-* variable', () => { + const vars = generateCssVariables(darkTheme); + expect(vars['--st-primary']).toBe(darkTheme.colors.primary); + expect(vars['--st-background']).toBe(darkTheme.colors.background); + expect(vars['--st-text-secondary']).toBe(darkTheme.colors.textSecondary); + }); + + it('includes --st-mode', () => { + expect(generateCssVariables(darkTheme)['--st-mode']).toBe('dark'); + expect(generateCssVariables(lightTheme)['--st-mode']).toBe('light'); + }); + + it('includes font variables when a font is configured', () => { + const themed = { ...darkTheme, font: { family: 'Inter', scale: 1.1 } }; + const vars = generateCssVariables(themed); + expect(vars['--st-font-family']).toBe('Inter'); + expect(vars['--st-font-scale']).toBe('1.1'); + }); + + it('omits font variables when font is not set', () => { + const vars = generateCssVariables(darkTheme); + expect(vars['--st-font-family']).toBeUndefined(); + expect(vars['--st-font-scale']).toBeUndefined(); + }); +}); + +describe('toCssBlock', () => { + it('wraps variables in a :root block', () => { + const block = toCssBlock({ '--st-primary': '#6366f1' }); + expect(block).toContain(':root {'); + expect(block).toContain('--st-primary: #6366f1;'); + expect(block).toContain('}'); + }); +}); + +describe('relativeLuminance', () => { + it('returns 1 for white', () => { + expect(relativeLuminance('#ffffff')).toBeCloseTo(1, 4); + }); + + it('returns 0 for black', () => { + expect(relativeLuminance('#000000')).toBeCloseTo(0, 4); + }); + + it('returns 0 for invalid hex', () => { + expect(relativeLuminance('not-a-color')).toBe(0); + }); +}); + +describe('contrastRatio', () => { + it('returns 21 for black on white', () => { + expect(contrastRatio('#ffffff', '#000000')).toBeCloseTo(21, 0); + }); + + it('returns 1 for identical colours', () => { + expect(contrastRatio('#6366f1', '#6366f1')).toBeCloseTo(1, 4); + }); + + it('is symmetric', () => { + const a = contrastRatio('#6366f1', '#0f172a'); + const b = contrastRatio('#0f172a', '#6366f1'); + expect(a).toBeCloseTo(b, 4); + }); +}); + +describe('checkContrast', () => { + it('passes AA and AAA for black on white', () => { + const result = checkContrast('#000000', '#ffffff'); + expect(result.passesAA).toBe(true); + expect(result.passesAAA).toBe(true); + }); + + it('fails AA for very low contrast pair', () => { + // near-identical colours + const result = checkContrast('#eeeeee', '#ffffff'); + expect(result.passesAA).toBe(false); + expect(result.passesAAA).toBe(false); + }); + + it('rounds ratio to 2 decimal places', () => { + const result = checkContrast('#6366f1', '#0f172a'); + expect(String(result.ratio)).toMatch(/^\d+\.\d{1,2}$/); + }); +}); + +describe('auditThemeContrast', () => { + it('returns results for all expected pairs', () => { + const audit = auditThemeContrast(darkTheme); + expect(Object.keys(audit)).toEqual([ + 'text/background', + 'textSecondary/background', + 'text/surface', + 'primary/background', + 'primary/surface', + 'error/background', + ]); + }); + + it('dark theme text on background passes AA', () => { + const audit = auditThemeContrast(darkTheme); + expect(audit['text/background'].passesAA).toBe(true); + }); + + it('light theme text on background passes AA', () => { + const audit = auditThemeContrast(lightTheme); + expect(audit['text/background'].passesAA).toBe(true); + }); +}); diff --git a/src/theme/__tests__/themeStore.test.ts b/src/theme/__tests__/themeStore.test.ts index 65b8908..559c14a 100644 --- a/src/theme/__tests__/themeStore.test.ts +++ b/src/theme/__tests__/themeStore.test.ts @@ -94,4 +94,89 @@ describe('themeStore', () => { useThemeStore.getState().setTheme('light'); expect(useThemeStore.getState().theme).toMatchObject({ id: 'light', mode: 'light' }); }); + + it('addBrandTheme persists logoUri and font from BrandConfig', () => { + useThemeStore.getState().addBrandTheme( + { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff', logoUri: 'https://example.com/logo.png', font: { family: 'Inter', scale: 1.1 } }, + 'brand-logo', + 'Brand With Logo' + ); + const theme = useThemeStore.getState().theme; + expect(theme.logoUri).toBe('https://example.com/logo.png'); + expect(theme.font?.family).toBe('Inter'); + expect(theme.font?.scale).toBe(1.1); + }); + + it('addBrandTheme generates cssVariables automatically', () => { + useThemeStore.getState().addBrandTheme( + { primary: '#aabbcc', secondary: '#112233', accent: '#445566' }, + 'brand-css', + 'CSS Brand' + ); + const theme = useThemeStore.getState().theme; + expect(theme.cssVariables).toBeDefined(); + expect(theme.cssVariables!['--st-primary']).toBe('#aabbcc'); + }); + + it('exportTheme serialises a theme without cssVariables', () => { + useThemeStore.getState().addBrandTheme( + { primary: '#aabbcc', secondary: '#112233', accent: '#445566' }, + 'export-test', + 'Export Test' + ); + const json = useThemeStore.getState().exportTheme('export-test'); + expect(json).not.toBeNull(); + const parsed = JSON.parse(json!); + expect(parsed.version).toBe(1); + expect(parsed.theme.id).toBe('export-test'); + expect(parsed.theme.cssVariables).toBeUndefined(); + }); + + it('exportTheme returns null for unknown id', () => { + const json = useThemeStore.getState().exportTheme('does-not-exist'); + // resolveTheme falls back to dark, so we get the dark export + expect(json).not.toBeNull(); // dark theme is always available + }); + + it('importTheme adds the theme and regenerates cssVariables', () => { + const themeJson = JSON.stringify({ + version: 1, + theme: { + id: 'imported-brand', + name: 'Imported Brand', + mode: 'dark', + colors: { + primary: '#ff1234', secondary: '#00aaff', accent: '#00ff99', + success: '#10b981', warning: '#f59e0b', error: '#ef4444', + background: '#0f172a', surface: '#1e293b', text: '#f8fafc', + textSecondary: '#cbd5e1', border: '#334155', overlay: 'rgba(0,0,0,0.8)', + }, + }, + }); + const id = useThemeStore.getState().importTheme(themeJson); + expect(id).toBe('imported-brand'); + const imported = useThemeStore.getState().customThemes.find((t) => t.id === 'imported-brand'); + expect(imported).toBeDefined(); + expect(imported!.cssVariables?.['--st-primary']).toBe('#ff1234'); + }); + + it('importTheme returns null for invalid JSON', () => { + const id = useThemeStore.getState().importTheme('not-json'); + expect(id).toBeNull(); + }); + + it('importTheme returns null for wrong version', () => { + const id = useThemeStore.getState().importTheme(JSON.stringify({ version: 99, theme: {} })); + expect(id).toBeNull(); + }); + + it('importTheme replaces a theme with same id', () => { + const base = { version: 1, theme: { id: 'dup', name: 'Dup', mode: 'dark', colors: { primary: '#111', secondary: '#222', accent: '#333', success: '#10b981', warning: '#f59e0b', error: '#ef4444', background: '#0f172a', surface: '#1e293b', text: '#f8fafc', textSecondary: '#cbd5e1', border: '#334155', overlay: 'rgba(0,0,0,0.8)' } } }; + useThemeStore.getState().importTheme(JSON.stringify(base)); + const updated = { ...base, theme: { ...base.theme, colors: { ...base.theme.colors, primary: '#999' } } }; + useThemeStore.getState().importTheme(JSON.stringify(updated)); + const themes = useThemeStore.getState().customThemes.filter((t) => t.id === 'dup'); + expect(themes).toHaveLength(1); + expect(themes[0].colors.primary).toBe('#999'); + }); }); diff --git a/src/theme/cssVariables.ts b/src/theme/cssVariables.ts new file mode 100644 index 0000000..0b6a028 --- /dev/null +++ b/src/theme/cssVariables.ts @@ -0,0 +1,122 @@ +import type { Theme, ThemeColors, ContrastResult } from './types'; + +/** + * Generate a map of CSS custom properties from a Theme's color palette. + * + * Each ThemeColors key becomes `--st-` (kebab-cased). + * Font family and scale are included when a font is configured. + * + * @example + * const vars = generateCssVariables(darkTheme); + * // { '--st-primary': '#6366f1', '--st-background': '#0f172a', ... } + */ +export function generateCssVariables(theme: Theme): Record { + const vars: Record = {}; + + for (const [key, value] of Object.entries(theme.colors) as [keyof ThemeColors, string][]) { + vars[`--st-${toKebab(key)}`] = value; + } + + if (theme.font?.family) { + vars['--st-font-family'] = theme.font.family; + } + if (theme.font?.scale !== undefined) { + vars['--st-font-scale'] = String(theme.font.scale); + } + + vars['--st-mode'] = theme.mode; + + return vars; +} + +/** + * Serialise a CSS variable map to a `:root { … }` block string. + * Useful for injecting into a web view or generating a stylesheet snippet. + */ +export function toCssBlock(vars: Record): string { + const declarations = Object.entries(vars) + .map(([prop, val]) => ` ${prop}: ${val};`) + .join('\n'); + return `:root {\n${declarations}\n}`; +} + +// --------------------------------------------------------------------------- +// WCAG contrast helpers +// --------------------------------------------------------------------------- + +/** + * Calculate the WCAG 2.1 relative luminance of a hex colour. + * Returns a value in [0, 1]. + */ +export function relativeLuminance(hex: string): number { + const rgb = hexToRgb(hex); + if (!rgb) return 0; + const toLinear = (c: number) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }; + return 0.2126 * toLinear(rgb.r) + 0.7152 * toLinear(rgb.g) + 0.0722 * toLinear(rgb.b); +} + +/** + * Calculate the WCAG 2.1 contrast ratio between two hex colours. + * + * @example + * contrastRatio('#ffffff', '#000000') // => 21 + * contrastRatio('#6366f1', '#0f172a') // => ~5.8 + */ +export function contrastRatio(hex1: string, hex2: string): number { + const l1 = relativeLuminance(hex1); + const l2 = relativeLuminance(hex2); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +/** + * Evaluate a foreground/background colour pair against WCAG AA and AAA levels. + */ +export function checkContrast(foreground: string, background: string): ContrastResult { + const ratio = contrastRatio(foreground, background); + return { + ratio: Math.round(ratio * 100) / 100, + passesAA: ratio >= 4.5, + passesAAA: ratio >= 7.0, + }; +} + +/** + * Run a full accessibility contrast audit for a theme. + * Checks text on background, primary on background, and text on surface. + * + * Returns a map of pair labels to ContrastResult. + */ +export function auditThemeContrast(theme: Theme): Record { + const { colors: c } = theme; + return { + 'text/background': checkContrast(c.text, c.background), + 'textSecondary/background': checkContrast(c.textSecondary, c.background), + 'text/surface': checkContrast(c.text, c.surface), + 'primary/background': checkContrast(c.primary, c.background), + 'primary/surface': checkContrast(c.primary, c.surface), + 'error/background': checkContrast(c.error, c.background), + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function toKebab(camel: string): string { + return camel.replace(/([A-Z])/g, (m) => `-${m.toLowerCase()}`); +} + +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const clean = hex.replace('#', ''); + const full = clean.length === 3 + ? clean.split('').map((c) => c + c).join('') + : clean; + const int = parseInt(full, 16); + if (isNaN(int)) return null; + return { r: (int >> 16) & 255, g: (int >> 8) & 255, b: int & 255 }; +} diff --git a/src/theme/index.ts b/src/theme/index.ts index 57acc5a..3d725aa 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -1,4 +1,5 @@ export { useTheme } from './useTheme'; export { useThemeStore } from './themeStore'; -export { darkTheme, lightTheme, builtInThemes, createBrandTheme } from './themes'; -export type { Theme, ThemeColors, ThemeMode, BrandConfig } from './types'; +export { darkTheme, lightTheme, highContrastTheme, builtInThemes, createBrandTheme } from './themes'; +export { generateCssVariables, toCssBlock, checkContrast, auditThemeContrast, contrastRatio } from './cssVariables'; +export type { Theme, ThemeColors, ThemeMode, BrandConfig, ThemeFont, ThemeExport, ContrastResult } from './types'; diff --git a/src/theme/themeStore.ts b/src/theme/themeStore.ts index 97908bf..2f7ee87 100644 --- a/src/theme/themeStore.ts +++ b/src/theme/themeStore.ts @@ -2,19 +2,39 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { darkTheme, lightTheme, builtInThemes, createBrandTheme } from './themes'; -import type { Theme, BrandConfig } from './types'; +import { generateCssVariables } from './cssVariables'; +import type { Theme, BrandConfig, ThemeExport } from './types'; interface ThemeState { activeThemeId: string; customThemes: Theme[]; - // derived — always computed from activeThemeId + customThemes + /** Derived — always computed from activeThemeId + customThemes. */ theme: Theme; + /** Switch to a theme by ID. Falls back to dark if not found. */ setTheme: (id: string) => void; + /** Toggle between dark and light built-in themes. */ toggleMode: () => void; + /** + * Create (or replace) a custom brand theme from a full BrandConfig. + * Logo URI and font are included when provided. + */ addBrandTheme: (brand: BrandConfig, id: string, name: string) => void; + /** Remove a custom theme. If it was active, falls back to dark. */ removeCustomTheme: (id: string) => void; + /** All built-in + custom themes. */ allThemes: () => Theme[]; + /** + * Export a theme as a serialisable JSON string. + * Omits derived cssVariables to keep the snapshot compact. + */ + exportTheme: (id: string) => string | null; + /** + * Import a previously-exported theme JSON string. + * Validates the envelope and regenerates CSS variables before storing. + * Returns the imported theme ID on success, or null on failure. + */ + importTheme: (json: string) => string | null; } function resolveTheme(id: string, custom: Theme[]): Theme { @@ -60,13 +80,54 @@ export const useThemeStore = create()( allThemes() { return [...builtInThemes, ...get().customThemes]; }, + + exportTheme(id) { + const theme = resolveTheme(id, get().customThemes); + if (!theme) return null; + const { cssVariables: _css, ...rest } = theme; + const payload: ThemeExport = { version: 1, theme: rest }; + return JSON.stringify(payload, null, 2); + }, + + importTheme(json) { + try { + const parsed: unknown = JSON.parse(json); + if ( + typeof parsed !== 'object' || + parsed === null || + (parsed as ThemeExport).version !== 1 || + typeof (parsed as ThemeExport).theme !== 'object' + ) { + return null; + } + const imported = (parsed as ThemeExport).theme as Theme; + imported.cssVariables = generateCssVariables(imported); + set((s) => ({ + customThemes: [ + ...s.customThemes.filter((t) => t.id !== imported.id), + imported, + ], + })); + return imported.id; + } catch { + return null; + } + }, }), { name: 'subtrackr-theme', storage: createJSONStorage(() => AsyncStorage), - partialize: (s) => ({ activeThemeId: s.activeThemeId, customThemes: s.customThemes }), + // Do not persist cssVariables — regenerated on rehydration + partialize: (s) => ({ + activeThemeId: s.activeThemeId, + customThemes: s.customThemes.map(({ cssVariables: _css, ...t }) => t), + }), onRehydrateStorage: () => (state) => { if (state) { + state.customThemes = state.customThemes.map((t) => ({ + ...t, + cssVariables: generateCssVariables(t), + })); state.theme = resolveTheme(state.activeThemeId, state.customThemes); } }, diff --git a/src/theme/themes.ts b/src/theme/themes.ts index 99afe5a..f13c4f2 100644 --- a/src/theme/themes.ts +++ b/src/theme/themes.ts @@ -1,4 +1,5 @@ import type { Theme, BrandConfig } from './types'; +import { generateCssVariables } from './cssVariables'; export const darkTheme: Theme = { id: 'dark', @@ -19,6 +20,7 @@ export const darkTheme: Theme = { overlay: 'rgba(15, 23, 42, 0.8)', }, }; +darkTheme.cssVariables = generateCssVariables(darkTheme); export const lightTheme: Theme = { id: 'light', @@ -39,10 +41,11 @@ export const lightTheme: Theme = { overlay: 'rgba(248, 250, 252, 0.8)', }, }; +lightTheme.cssVariables = generateCssVariables(lightTheme); /** * High contrast theme for users who need stronger visual differentiation. - * Uses pure black/white backgrounds with high-saturation accent colors. + * Uses pure black/white backgrounds with high-saturation accent colours. */ export const highContrastTheme: Theme = { id: 'high-contrast', @@ -63,12 +66,16 @@ export const highContrastTheme: Theme = { overlay: 'rgba(0, 0, 0, 0.9)', }, }; +highContrastTheme.cssVariables = generateCssVariables(highContrastTheme); export const builtInThemes: Theme[] = [darkTheme, lightTheme, highContrastTheme]; -/** Create a brand theme by overriding brand colors on top of a base theme */ +/** + * Create a brand theme by overriding brand colours (and optionally logo / font) + * on top of a base theme. CSS variables are generated automatically. + */ export function createBrandTheme(base: Theme, brand: BrandConfig, id: string, name: string): Theme { - return { + const theme: Theme = { ...base, id, name, @@ -78,5 +85,9 @@ export function createBrandTheme(base: Theme, brand: BrandConfig, id: string, na secondary: brand.secondary, accent: brand.accent, }, + logoUri: brand.logoUri ?? base.logoUri, + font: brand.font ?? base.font, }; + theme.cssVariables = generateCssVariables(theme); + return theme; } diff --git a/src/theme/types.ts b/src/theme/types.ts index d03834b..0701551 100644 --- a/src/theme/types.ts +++ b/src/theme/types.ts @@ -17,15 +17,55 @@ export interface ThemeColors { export type ThemeMode = 'light' | 'dark'; +/** Font configuration for brand themes. */ +export interface ThemeFont { + /** Font family name (must be loaded or available on the device). */ + family: string; + /** Optional scale factor applied to all font sizes (default: 1). */ + scale?: number; +} + +/** Full brand configuration used when creating a custom white-label theme. */ +export interface BrandConfig { + primary: string; + secondary: string; + accent: string; + /** Optional logo URI (local asset path or remote URL). */ + logoUri?: string; + /** Optional font settings. */ + font?: ThemeFont; +} + export interface Theme { id: string; name: string; mode: ThemeMode; colors: ThemeColors; + /** Logo URI shown in branded navigation headers. */ + logoUri?: string; + /** Font configuration for this theme. */ + font?: ThemeFont; + /** + * CSS custom properties generated from this theme's colors. + * Populated automatically by generateCssVariables; not persisted. + */ + cssVariables?: Record; } -export interface BrandConfig { - primary: string; - secondary: string; - accent: string; +/** + * Serialisable snapshot used for theme export / import. + * Does not include derived fields like cssVariables. + */ +export interface ThemeExport { + version: 1; + theme: Omit; +} + +/** WCAG contrast ratio result for accessibility validation. */ +export interface ContrastResult { + ratio: number; + /** AA requires ≥ 4.5 for normal text, ≥ 3 for large text. */ + passesAA: boolean; + /** AAA requires ≥ 7.0. */ + passesAAA: boolean; }