diff --git a/.github/workflows/sdk-generate.yml b/.github/workflows/sdk-generate.yml new file mode 100644 index 00000000..0a6a25d0 --- /dev/null +++ b/.github/workflows/sdk-generate.yml @@ -0,0 +1,72 @@ +name: SDK Auto-Generation + +on: + push: + paths: + - 'spec/openapi.yaml' + - 'scripts/sdk-generate.sh' + - '.github/workflows/sdk-generate.yml' + pull_request: + paths: + - 'spec/openapi.yaml' + +jobs: + validate-spec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate OpenAPI spec + uses: mbowman100/swagger-validator-action@v1 + with: + files: spec/openapi.yaml + - name: Check breaking changes + uses: ponelat/oas-breaking-changes-action@v1 + with: + spec-file: spec/openapi.yaml + old-spec-file: docs/openapi.yaml + + generate-sdks: + needs: validate-spec + runs-on: ubuntu-latest + strategy: + matrix: + language: [javascript, python, go] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + if: matrix.language == 'python' + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + if: matrix.language == 'go' + - name: Generate ${{ matrix.language }} SDK + run: bash scripts/sdk-generate.sh ${{ matrix.language }} + - name: Check for changes + id: diff + run: | + if [ -n "$(git status --porcelain sdks/${{ matrix.language }}/)" ]; then + echo "changed=true" >> $GITHUB_OUTPUT + else + echo "changed=false" >> $GITHUB_OUTPUT + fi + - name: Create PR for SDK changes + if: steps.diff.outputs.changed == 'true' && github.event_name == 'push' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH="sdk-auto/${{ matrix.language }}-$(date +%s)" + git checkout -b "$BRANCH" + git add sdks/${{ matrix.language }}/ + git commit -m "chore(sdk): auto-generate ${{ matrix.language }} SDK from OpenAPI spec" + git push origin "$BRANCH" + gh pr create \ + --base main \ + --title "chore(sdk): auto-generate ${{ matrix.language }} SDK" \ + --body "Auto-generated ${{ matrix.language }} SDK from OpenAPI spec changes in \`spec/openapi.yaml\`." \ + --label automated diff --git a/backend/analytics/command/index.ts b/backend/analytics/command/index.ts new file mode 100644 index 00000000..2df93448 --- /dev/null +++ b/backend/analytics/command/index.ts @@ -0,0 +1,2 @@ +export { SubscriptionCommandHandler } from './subscriptionCommandHandler'; +export type { CreateSubscriptionCommand, CancelSubscriptionCommand } from './subscriptionCommandHandler'; diff --git a/backend/analytics/command/subscriptionCommandHandler.ts b/backend/analytics/command/subscriptionCommandHandler.ts new file mode 100644 index 00000000..719e924f --- /dev/null +++ b/backend/analytics/command/subscriptionCommandHandler.ts @@ -0,0 +1,50 @@ +import { QueryClient } from '../../../backend/shared/query/queryRouter'; + +export interface CreateSubscriptionCommand { + id: string; + planId: string; + userId: string; + amount: number; + currency: string; + billingCycle: string; + nextBillingDate: Date; + metadata?: Record; +} + +export interface CancelSubscriptionCommand { + id: string; + userId: string; + reason?: string; +} + +export class SubscriptionCommandHandler { + constructor(private db: QueryClient) {} + + async create(cmd: CreateSubscriptionCommand): Promise { + await this.db.query( + `INSERT INTO subscriptions + (id, plan_id, user_id, amount, currency, billing_cycle, status, next_billing_date, metadata, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8, NOW(), NOW())`, + [ + cmd.id, + cmd.planId, + cmd.userId, + cmd.amount, + cmd.currency, + cmd.billingCycle, + cmd.nextBillingDate, + cmd.metadata ? JSON.stringify(cmd.metadata) : null, + ], + ); + } + + async cancel(cmd: CancelSubscriptionCommand): Promise { + await this.db.query( + `UPDATE subscriptions + SET status = 'cancelled', cancelled_at = NOW(), updated_at = NOW(), + cancellation_reason = $3 + WHERE id = $1 AND user_id = $2`, + [cmd.id, cmd.userId, cmd.reason ?? null], + ); + } +} diff --git a/backend/analytics/jobs/mvRefreshJob.ts b/backend/analytics/jobs/mvRefreshJob.ts index e6a35428..6ff9d8e1 100644 --- a/backend/analytics/jobs/mvRefreshJob.ts +++ b/backend/analytics/jobs/mvRefreshJob.ts @@ -1,22 +1,8 @@ -/** - * Materialized View Refresh Job - * - * Incrementally refreshes each materialized view using - * REFRESH MATERIALIZED VIEW CONCURRENTLY so reads are never blocked. - * - * Runs on a configurable interval (default 60 s for real-time views). - * Exposes a Prometheus-style metric for view freshness monitoring. - */ - import { QueryClient } from '../../../backend/shared/query/queryRouter'; -// ── View definitions ────────────────────────────────────────────────────────── - interface ViewConfig { name: string; - /** Refresh interval in ms. */ intervalMs: number; - /** Last successful refresh timestamp. */ lastRefreshedAt: Date | null; isRefreshing: boolean; } @@ -26,10 +12,11 @@ const DEFAULT_VIEWS: ViewConfig[] = [ { name: 'subscriber_balance_mv', intervalMs: 60_000, lastRefreshedAt: null, isRefreshing: false }, { name: 'monthly_revenue_mv', intervalMs: 300_000, lastRefreshedAt: null, isRefreshing: false }, { name: 'churn_summary_mv', intervalMs: 300_000, lastRefreshedAt: null, isRefreshing: false }, + { name: 'mrr_mv', intervalMs: 300_000, lastRefreshedAt: null, isRefreshing: false }, + { name: 'cohort_retention_mv', intervalMs: 3_600_000, lastRefreshedAt: null, isRefreshing: false }, + { name: 'ltv_mv', intervalMs: 86_400_000, lastRefreshedAt: null, isRefreshing: false }, ]; -// ── Types ───────────────────────────────────────────────────────────────────── - export interface RefreshMetric { viewName: string; lastRefreshedAt: Date | null; @@ -37,8 +24,6 @@ export interface RefreshMetric { isStale: boolean; } -// ── Job ─────────────────────────────────────────────────────────────────────── - export class MVRefreshJob { private db: QueryClient; private views: ViewConfig[]; @@ -52,10 +37,7 @@ export class MVRefreshJob { start(): void { for (const view of this.views) { if (this.timers.has(view.name)) continue; - - // Run immediately on start, then on interval void this.refresh(view.name); - const timer = setInterval( () => void this.refresh(view.name), view.intervalMs, @@ -71,7 +53,6 @@ export class MVRefreshJob { this.timers.clear(); } - /** Refresh a single view by name. Skips if already refreshing. */ async refresh(viewName: string): Promise { const view = this.views.find((v) => v.name === viewName); if (!view || view.isRefreshing) return; @@ -80,7 +61,6 @@ export class MVRefreshJob { const start = Date.now(); try { - // CONCURRENTLY requires a unique index on the view — see migration 002 await this.db.query(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${viewName}`); view.lastRefreshedAt = new Date(); console.info(`[MVRefreshJob] Refreshed ${viewName} in ${Date.now() - start}ms`); @@ -91,7 +71,6 @@ export class MVRefreshJob { } } - /** Return freshness metrics for all views (used by monitoring service). */ getMetrics(): RefreshMetric[] { return this.views.map((view) => { const lagMs = view.lastRefreshedAt @@ -106,13 +85,6 @@ export class MVRefreshJob { }); } - /** - * Prometheus-style text format for scraping. - * - * Metrics exposed: - * subtrackr_mv_lag_ms{view="..."} – lag in milliseconds - * subtrackr_mv_is_stale{view="..."} – 1 if stale, 0 if fresh - */ prometheusMetrics(): string { const lines: string[] = [ '# HELP subtrackr_mv_lag_ms Materialized view refresh lag in milliseconds', diff --git a/backend/analytics/query/cohortRetentionQueryHandler.ts b/backend/analytics/query/cohortRetentionQueryHandler.ts new file mode 100644 index 00000000..cc876470 --- /dev/null +++ b/backend/analytics/query/cohortRetentionQueryHandler.ts @@ -0,0 +1,57 @@ +import { QueryClient } from '../../../backend/shared/query/queryRouter'; + +export interface CohortRetentionPeriod { + period: number; + retained: number; + retentionPct: number; +} + +export interface CohortRetentionResult { + cohort: string; + periods: CohortRetentionPeriod[]; +} + +export class CohortRetentionQueryHandler { + constructor(private db: QueryClient) {} + + async getCohortRetention(cohort?: string): Promise { + let sql = ` + SELECT + cohort, + period, + retained, + retention_pct AS "retentionPct" + FROM cohort_retention_mv + WHERE 1=1 + `; + const params: unknown[] = []; + if (cohort) { + params.push(cohort); + sql += ` AND cohort = $${params.length}`; + } + sql += ' ORDER BY cohort, period'; + const result = await this.db.query<{ + cohort: string; + period: number; + retained: number; + retentionPct: number; + }>(sql, params); + + const grouped: Map = new Map(); + for (const row of result.rows) { + if (!grouped.has(row.cohort)) { + grouped.set(row.cohort, []); + } + grouped.get(row.cohort)!.push({ + period: row.period, + retained: row.retained, + retentionPct: row.retentionPct, + }); + } + + return Array.from(grouped.entries()).map(([cohort, periods]) => ({ + cohort, + periods, + })); + } +} diff --git a/backend/analytics/query/index.ts b/backend/analytics/query/index.ts new file mode 100644 index 00000000..de45860c --- /dev/null +++ b/backend/analytics/query/index.ts @@ -0,0 +1,6 @@ +export { MRRQueryHandler } from './mrrQueryHandler'; +export type { MRRQueryResult } from './mrrQueryHandler'; +export { CohortRetentionQueryHandler } from './cohortRetentionQueryHandler'; +export type { CohortRetentionResult, CohortRetentionPeriod } from './cohortRetentionQueryHandler'; +export { LTVQueryHandler } from './ltvQueryHandler'; +export type { LTVQueryResult } from './ltvQueryHandler'; diff --git a/backend/analytics/query/ltvQueryHandler.ts b/backend/analytics/query/ltvQueryHandler.ts new file mode 100644 index 00000000..57b6ca07 --- /dev/null +++ b/backend/analytics/query/ltvQueryHandler.ts @@ -0,0 +1,40 @@ +import { QueryClient } from '../../../backend/shared/query/queryRouter'; + +export interface LTVQueryResult { + month: string; + averageLtv: number; + medianLtv: number; + p25Ltv: number; + p75Ltv: number; + refreshedAt: Date; +} + +export class LTVQueryHandler { + constructor(private db: QueryClient) {} + + async getLTV(from?: string, to?: string): Promise { + let sql = ` + SELECT + month, + average_ltv AS "averageLtv", + median_ltv AS "medianLtv", + p25_ltv AS "p25Ltv", + p75_ltv AS "p75Ltv", + refreshed_at AS "refreshedAt" + FROM ltv_mv + WHERE 1=1 + `; + const params: unknown[] = []; + if (from) { + params.push(from); + sql += ` AND month >= $${params.length}`; + } + if (to) { + params.push(to); + sql += ` AND month <= $${params.length}`; + } + sql += ' ORDER BY month DESC'; + const result = await this.db.query(sql, params); + return result.rows; + } +} diff --git a/backend/analytics/query/mrrQueryHandler.ts b/backend/analytics/query/mrrQueryHandler.ts new file mode 100644 index 00000000..c4a64743 --- /dev/null +++ b/backend/analytics/query/mrrQueryHandler.ts @@ -0,0 +1,42 @@ +import { QueryClient } from '../../../backend/shared/query/queryRouter'; + +export interface MRRQueryResult { + month: string; + mrr: number; + newSubscriptions: number; + upgrades: number; + downgrades: number; + churn: number; + refreshedAt: Date; +} + +export class MRRQueryHandler { + constructor(private db: QueryClient) {} + + async getMRR(from?: string, to?: string): Promise { + let sql = ` + SELECT + month, + mrr, + new_subscriptions AS "newSubscriptions", + upgrades, + downgrades, + churn, + refreshed_at AS "refreshedAt" + FROM mrr_mv + WHERE 1=1 + `; + const params: unknown[] = []; + if (from) { + params.push(from); + sql += ` AND month >= $${params.length}`; + } + if (to) { + params.push(to); + sql += ` AND month <= $${params.length}`; + } + sql += ' ORDER BY month DESC'; + const result = await this.db.query(sql, params); + return result.rows; + } +} diff --git a/backend/audit/controller/auditController.ts b/backend/audit/controller/auditController.ts new file mode 100644 index 00000000..1cf96db2 --- /dev/null +++ b/backend/audit/controller/auditController.ts @@ -0,0 +1,83 @@ +import { HashChainService } from '../domain/HashChainService'; +import { AuditWriter, AuditEventInput } from '../domain/AuditWriter'; +import { BlockchainAnchor } from '../domain/BlockchainAnchor'; + +export interface AuditQueryFilter { + actorId?: string; + action?: string; + resourceType?: string; + resourceId?: string; + from?: number; + to?: number; + page?: number; + limit?: number; +} + +export interface AuditQueryResult { + data: unknown[]; + meta: { page: number; limit: number; total: number; totalPages: number }; +} + +export interface AuditVerificationResult { + valid: boolean; + firstInvalidIndex: number | null; + checkedEntries: number; + anchoredEntries: number; + lastAnchoredAt: number | null; +} + +export class AuditController { + constructor( + private chain: HashChainService, + private writer: AuditWriter, + private anchor: BlockchainAnchor, + ) {} + + async record(input: AuditEventInput): Promise { + return this.writer.write(input); + } + + async query(filter: AuditQueryFilter): Promise { + let events = [...this.chain.getChain()]; + if (filter.actorId) { + events = events.filter((e) => e.actorId === filter.actorId); + } + if (filter.action) { + events = events.filter((e) => e.action === filter.action); + } + if (filter.resourceType) { + events = events.filter((e) => e.resourceType === filter.resourceType); + } + if (filter.resourceId) { + events = events.filter((e) => e.resourceId === filter.resourceId); + } + if (filter.from) { + events = events.filter((e) => e.timestamp >= filter.from!); + } + if (filter.to) { + events = events.filter((e) => e.timestamp <= filter.to!); + } + events.sort((a, b) => b.timestamp - a.timestamp); + + const page = filter.page ?? 1; + const limit = filter.limit ?? 50; + const total = events.length; + const totalPages = Math.ceil(total / limit); + const start = (page - 1) * limit; + const data = events.slice(start, start + limit); + + return { data, meta: { page, limit, total, totalPages } }; + } + + async verify(): Promise { + const result = this.chain.verify(); + const lastAnchor = this.anchor.getLastAnchor(); + return { + valid: result.valid, + firstInvalidIndex: result.firstInvalidIndex, + checkedEntries: this.chain.getChainLength(), + anchoredEntries: this.anchor.getAnchorCount(), + lastAnchoredAt: lastAnchor?.anchoredAt ?? null, + }; + } +} diff --git a/backend/audit/controller/index.ts b/backend/audit/controller/index.ts new file mode 100644 index 00000000..275b91de --- /dev/null +++ b/backend/audit/controller/index.ts @@ -0,0 +1,2 @@ +export { AuditController } from './auditController'; +export type { AuditQueryFilter, AuditQueryResult, AuditVerificationResult } from './auditController'; diff --git a/backend/audit/domain/AuditWriter.ts b/backend/audit/domain/AuditWriter.ts new file mode 100644 index 00000000..00bd60c9 --- /dev/null +++ b/backend/audit/domain/AuditWriter.ts @@ -0,0 +1,33 @@ +import { HashChainService, AuditChainEntry } from './HashChainService'; + +export interface AuditEventInput { + actorId: string; + action: string; + resourceType: string; + resourceId: string; + oldState?: Record | null; + newState?: Record | null; + metadata?: Record; +} + +export class AuditWriter { + constructor(private chain: HashChainService) {} + + write(input: AuditEventInput): AuditChainEntry { + return this.chain.append({ + id: crypto.randomUUID(), + actorId: input.actorId, + action: input.action, + resourceType: input.resourceType, + resourceId: input.resourceId, + oldState: input.oldState ?? null, + newState: input.newState ?? null, + timestamp: Date.now(), + metadata: input.metadata ?? {}, + }); + } + + writeBatch(inputs: AuditEventInput[]): AuditChainEntry[] { + return inputs.map((input) => this.write(input)); + } +} diff --git a/backend/audit/domain/BlockchainAnchor.ts b/backend/audit/domain/BlockchainAnchor.ts new file mode 100644 index 00000000..1daad8b4 --- /dev/null +++ b/backend/audit/domain/BlockchainAnchor.ts @@ -0,0 +1,58 @@ +import { HashChainService } from './HashChainService'; + +export interface AnchorRecord { + chainHeadHash: string; + chainLength: number; + stellarTxHash: string; + anchoredAt: number; +} + +const ANCHOR_INTERVAL_ENTRIES = 1000; +const ANCHOR_INTERVAL_MS = 24 * 60 * 60 * 1000; + +export class BlockchainAnchor { + private anchors: AnchorRecord[] = []; + private chain: HashChainService; + private anchorIntervalEntries: number; + private anchorIntervalMs: number; + + constructor( + chain: HashChainService, + opts?: { anchorIntervalEntries?: number; anchorIntervalMs?: number }, + ) { + this.chain = chain; + this.anchorIntervalEntries = opts?.anchorIntervalEntries ?? ANCHOR_INTERVAL_ENTRIES; + this.anchorIntervalMs = opts?.anchorIntervalMs ?? ANCHOR_INTERVAL_MS; + } + + shouldAnchor(): boolean { + if (this.anchors.length === 0) return this.chain.getChainLength() > 0; + const last = this.anchors[this.anchors.length - 1]; + const elapsed = Date.now() - last.anchoredAt; + const entriesSinceAnchor = this.chain.getChainLength() - last.chainLength; + return elapsed >= this.anchorIntervalMs || entriesSinceAnchor >= this.anchorIntervalEntries; + } + + async anchor(headHash: string, stellarTxHash: string): Promise { + const record: AnchorRecord = { + chainHeadHash: headHash, + chainLength: this.chain.getChainLength(), + stellarTxHash, + anchoredAt: Date.now(), + }; + this.anchors.push(record); + return record; + } + + getAnchors(): readonly AnchorRecord[] { + return this.anchors; + } + + getLastAnchor(): AnchorRecord | null { + return this.anchors.length > 0 ? this.anchors[this.anchors.length - 1] : null; + } + + getAnchorCount(): number { + return this.anchors.length; + } +} diff --git a/backend/audit/domain/HashChainService.ts b/backend/audit/domain/HashChainService.ts new file mode 100644 index 00000000..7cf94140 --- /dev/null +++ b/backend/audit/domain/HashChainService.ts @@ -0,0 +1,87 @@ +import { createHash } from 'crypto'; + +export interface AuditChainEntry { + id: string; + actorId: string; + action: string; + resourceType: string; + resourceId: string; + oldState: Record | null; + newState: Record | null; + timestamp: number; + prevHash: string; + hash: string; + metadata: Record; +} + +const GENESIS_HASH = '0'.repeat(64); + +export class HashChainService { + private chain: AuditChainEntry[] = []; + + constructor() {} + + getGenesisHash(): string { + return GENESIS_HASH; + } + + getChain(): readonly AuditChainEntry[] { + return this.chain; + } + + getChainHead(): AuditChainEntry | null { + return this.chain.length > 0 ? this.chain[this.chain.length - 1] : null; + } + + append(entry: Omit): AuditChainEntry { + const prevHash = this.getChainHead()?.hash ?? GENESIS_HASH; + const hash = this.computeHash({ ...entry, prevHash }); + const chainEntry: AuditChainEntry = { ...entry, prevHash, hash }; + this.chain.push(chainEntry); + return chainEntry; + } + + computeHash(data: { id: string; actorId: string; action: string; resourceType: string; resourceId: string; timestamp: number; prevHash: string }): string { + return createHash('sha256') + .update(prevHash) + .update(data.id) + .update(data.actorId) + .update(data.action) + .update(data.resourceType) + .update(data.resourceId) + .update(String(data.timestamp)) + .digest('hex'); + } + + verify(): { valid: boolean; firstInvalidIndex: number | null } { + let prev = GENESIS_HASH; + for (let i = 0; i < this.chain.length; i++) { + const e = this.chain[i]; + if (e.prevHash !== prev) { + return { valid: false, firstInvalidIndex: i }; + } + const expected = this.computeHash({ + id: e.id, + actorId: e.actorId, + action: e.action, + resourceType: e.resourceType, + resourceId: e.resourceId, + timestamp: e.timestamp, + prevHash: e.prevHash, + }); + if (expected !== e.hash) { + return { valid: false, firstInvalidIndex: i }; + } + prev = e.hash; + } + return { valid: true, firstInvalidIndex: null }; + } + + getChainSegment(fromIndex: number, toIndex: number): AuditChainEntry[] { + return this.chain.slice(fromIndex, toIndex + 1); + } + + getChainLength(): number { + return this.chain.length; + } +} diff --git a/backend/audit/domain/index.ts b/backend/audit/domain/index.ts new file mode 100644 index 00000000..44758d89 --- /dev/null +++ b/backend/audit/domain/index.ts @@ -0,0 +1,6 @@ +export { HashChainService } from './HashChainService'; +export type { AuditChainEntry } from './HashChainService'; +export { AuditWriter } from './AuditWriter'; +export type { AuditEventInput } from './AuditWriter'; +export { BlockchainAnchor } from './BlockchainAnchor'; +export type { AnchorRecord } from './BlockchainAnchor'; diff --git a/backend/audit/index.ts b/backend/audit/index.ts new file mode 100644 index 00000000..895671cf --- /dev/null +++ b/backend/audit/index.ts @@ -0,0 +1,8 @@ +export { HashChainService, AuditWriter, BlockchainAnchor } from './domain'; +export type { AuditChainEntry, AuditEventInput, AnchorRecord } from './domain'; +export { AuditController } from './controller'; +export type { AuditQueryFilter, AuditQueryResult, AuditVerificationResult } from './controller'; +export { LogRotationJob, IntegrityCheckerJob, BlockchainAnchorJob } from './jobs'; +export type { IntegrityCheckResult } from './jobs'; +export { AuditLoggingMiddleware } from '../shared/middleware/auditLoggingMiddleware'; +export type { RequestContext } from '../shared/middleware/auditLoggingMiddleware'; diff --git a/backend/audit/jobs/blockchainAnchorJob.ts b/backend/audit/jobs/blockchainAnchorJob.ts new file mode 100644 index 00000000..7017bb09 --- /dev/null +++ b/backend/audit/jobs/blockchainAnchorJob.ts @@ -0,0 +1,48 @@ +import { BlockchainAnchor } from '../domain/BlockchainAnchor'; +import { HashChainService } from '../domain/HashChainService'; + +export class BlockchainAnchorJob { + private anchor: BlockchainAnchor; + private chain: HashChainService; + private anchorStellar: (headHash: string) => Promise; + private timer: ReturnType | null = null; + + constructor( + anchor: BlockchainAnchor, + chain: HashChainService, + anchorStellar: (headHash: string) => Promise, + ) { + this.anchor = anchor; + this.chain = chain; + this.anchorStellar = anchorStellar; + } + + start(intervalMs: number = 86_400_000): void { + if (this.timer) return; + void this.tryAnchor(); + this.timer = setInterval(() => void this.tryAnchor(), intervalMs); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + private async tryAnchor(): Promise { + if (!this.anchor.shouldAnchor()) return; + + try { + const head = this.chain.getChainHead(); + if (!head) return; + const stellarTxHash = await this.anchorStellar(head.hash); + await this.anchor.anchor(head.hash, stellarTxHash); + console.info( + `[BlockchainAnchorJob] Anchored chain head ${head.hash.slice(0, 16)}... to Stellar tx ${stellarTxHash}`, + ); + } catch (err) { + console.error('[BlockchainAnchorJob] Anchoring failed:', err); + } + } +} diff --git a/backend/audit/jobs/index.ts b/backend/audit/jobs/index.ts new file mode 100644 index 00000000..5c1d9bbd --- /dev/null +++ b/backend/audit/jobs/index.ts @@ -0,0 +1,4 @@ +export { LogRotationJob } from './logRotationJob'; +export { IntegrityCheckerJob } from './integrityCheckerJob'; +export type { IntegrityCheckResult } from './integrityCheckerJob'; +export { BlockchainAnchorJob } from './blockchainAnchorJob'; diff --git a/backend/audit/jobs/integrityCheckerJob.ts b/backend/audit/jobs/integrityCheckerJob.ts new file mode 100644 index 00000000..673f3e1a --- /dev/null +++ b/backend/audit/jobs/integrityCheckerJob.ts @@ -0,0 +1,61 @@ +import { HashChainService } from '../domain/HashChainService'; + +export interface IntegrityCheckResult { + passed: boolean; + checkedEntries: number; + firstInvalidIndex: number | null; + checkedAt: number; +} + +export class IntegrityCheckerJob { + private chain: HashChainService; + private intervalMs: number; + private onViolation: ((result: IntegrityCheckResult) => void) | null; + private timer: ReturnType | null = null; + + constructor( + chain: HashChainService, + opts?: { + intervalMs?: number; + onViolation?: (result: IntegrityCheckResult) => void; + }, + ) { + this.chain = chain; + this.intervalMs = opts?.intervalMs ?? 3_600_000; + this.onViolation = opts?.onViolation ?? null; + } + + start(): void { + if (this.timer) return; + void this.check(); + this.timer = setInterval(() => void this.check(), this.intervalMs); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + private async check(): Promise { + const result = this.chain.verify(); + const checkResult: IntegrityCheckResult = { + passed: result.valid, + checkedEntries: this.chain.getChainLength(), + firstInvalidIndex: result.firstInvalidIndex, + checkedAt: Date.now(), + }; + + if (!checkResult.passed) { + console.error( + `[IntegrityChecker] Hash chain integrity VIOLATION at index ${checkResult.firstInvalidIndex}`, + ); + if (this.onViolation) { + this.onViolation(checkResult); + } + } else { + console.info(`[IntegrityChecker] Chain integrity OK (${checkResult.checkedEntries} entries)`); + } + } +} diff --git a/backend/audit/jobs/logRotationJob.ts b/backend/audit/jobs/logRotationJob.ts new file mode 100644 index 00000000..1fea8c56 --- /dev/null +++ b/backend/audit/jobs/logRotationJob.ts @@ -0,0 +1,40 @@ +const DEFAULT_RETENTION_MS = 7 * 365 * 24 * 60 * 60 * 1000; + +export class LogRotationJob { + private retentionMs: number; + private intervalMs: number; + private timer: ReturnType | null = null; + private onRotate: (cutoff: number) => Promise; + + constructor( + onRotate: (cutoff: number) => Promise, + opts?: { retentionMs?: number; intervalMs?: number }, + ) { + this.onRotate = onRotate; + this.retentionMs = opts?.retentionMs ?? DEFAULT_RETENTION_MS; + this.intervalMs = opts?.intervalMs ?? 86_400_000; + } + + start(): void { + if (this.timer) return; + void this.rotate(); + this.timer = setInterval(() => void this.rotate(), this.intervalMs); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + private async rotate(): Promise { + const cutoff = Date.now() - this.retentionMs; + try { + await this.onRotate(cutoff); + console.info(`[LogRotationJob] Rotation complete. Cutoff: ${new Date(cutoff).toISOString()}`); + } catch (err) { + console.error('[LogRotationJob] Rotation failed:', err); + } + } +} diff --git a/backend/auth/benchmarkConsentService.ts b/backend/auth/benchmarkConsentService.ts new file mode 100644 index 00000000..f287de1b --- /dev/null +++ b/backend/auth/benchmarkConsentService.ts @@ -0,0 +1,69 @@ +export interface BenchmarkConsent { + userId: string; + optedIn: boolean; + vertical: string | null; + region: string | null; + companySize: string | null; + revenueBand: string | null; + consentedAt: Date; + expiresAt: Date; +} + +const CONSENT_DURATION_MS = 365 * 24 * 60 * 60 * 1000; + +export class BenchmarkConsentService { + private consents: Map = new Map(); + + async getConsent(userId: string): Promise { + const consent = this.consents.get(userId); + if (!consent) return null; + if (consent.expiresAt < new Date()) { + this.consents.delete(userId); + return null; + } + return consent; + } + + async setConsent( + userId: string, + optedIn: boolean, + opts?: { + vertical?: string; + region?: string; + companySize?: string; + revenueBand?: string; + }, + ): Promise { + const consent: BenchmarkConsent = { + userId, + optedIn, + vertical: opts?.vertical ?? null, + region: opts?.region ?? null, + companySize: opts?.companySize ?? null, + revenueBand: opts?.revenueBand ?? null, + consentedAt: new Date(), + expiresAt: new Date(Date.now() + CONSENT_DURATION_MS), + }; + this.consents.set(userId, consent); + return consent; + } + + async revokeConsent(userId: string): Promise { + this.consents.delete(userId); + } + + async getOptedInUsers(): Promise { + const users: string[] = []; + const now = new Date(); + for (const [userId, consent] of this.consents) { + if (consent.optedIn && consent.expiresAt > now) { + users.push(userId); + } + } + return users; + } + + async purgeUserData(userId: string): Promise { + this.consents.delete(userId); + } +} diff --git a/backend/auth/index.ts b/backend/auth/index.ts new file mode 100644 index 00000000..38119edb --- /dev/null +++ b/backend/auth/index.ts @@ -0,0 +1,2 @@ +export { BenchmarkConsentService } from './benchmarkConsentService'; +export type { BenchmarkConsent } from './benchmarkConsentService'; diff --git a/backend/benchmark/BenchmarkEngine.ts b/backend/benchmark/BenchmarkEngine.ts new file mode 100644 index 00000000..0fd7d8c0 --- /dev/null +++ b/backend/benchmark/BenchmarkEngine.ts @@ -0,0 +1,123 @@ +const EPSILON = 1.0; +const MIN_COHORT_SIZE = 10; + +export interface MerchantMetrics { + merchantId: string; + mrrGrowth: number; + churnRate: number; + conversionRate: number; + arpa: number; +} + +export interface BenchmarkMetric { + merchantValue: number; + p25: number; + p50: number; + p75: number; + unit: string; + cohortSize: number; +} + +export interface BenchmarkReport { + merchantId: string; + vertical: string; + region: string; + companySize: string; + generatedAt: Date; + metrics: { + mrrGrowth: BenchmarkMetric; + churnRate: BenchmarkMetric; + conversionRate: BenchmarkMetric; + arpa: BenchmarkMetric; + }; + trend: 'improving' | 'declining' | 'stable'; +} + +function addLaplaceNoise(value: number, epsilon: number, sensitivity: number): number { + const scale = sensitivity / epsilon; + const u = Math.random() - 0.5; + return value + scale * Math.sign(u) * Math.log(1 - 2 * Math.abs(u)); +} + +export class BenchmarkEngine { + generateReport( + merchant: MerchantMetrics, + peers: MerchantMetrics[], + vertical: string, + region: string, + companySize: string, + ): BenchmarkReport | null { + if (peers.length < MIN_COHORT_SIZE) { + return null; + } + + const computeMetric = ( + merchantVal: number, + values: number[], + unit: string, + ): BenchmarkMetric => { + const sorted = [...values].sort((a, b) => a - b); + const len = sorted.length; + const p25 = sorted[Math.floor(len * 0.25)]; + const p50 = sorted[Math.floor(len * 0.5)]; + const p75 = sorted[Math.floor(len * 0.75)]; + + return { + merchantValue: addLaplaceNoise(merchantVal, EPSILON, 0.1), + p25: addLaplaceNoise(p25, EPSILON, 0.1), + p50: addLaplaceNoise(p50, EPSILON, 0.1), + p75: addLaplaceNoise(p75, EPSILON, 0.1), + unit, + cohortSize: len, + }; + }; + + const peerValues = peers.map((p) => p); + + return { + merchantId: merchant.merchantId, + vertical, + region, + companySize, + generatedAt: new Date(), + metrics: { + mrrGrowth: computeMetric( + merchant.mrrGrowth, + peerValues.map((p) => p.mrrGrowth), + '%', + ), + churnRate: computeMetric( + merchant.churnRate, + peerValues.map((p) => p.churnRate), + '%', + ), + conversionRate: computeMetric( + merchant.conversionRate, + peerValues.map((p) => p.conversionRate), + '%', + ), + arpa: computeMetric( + merchant.arpa, + peerValues.map((p) => p.arpa), + 'USD', + ), + }, + trend: this.determineTrend(merchant, peerValues), + }; + } + + private determineTrend( + merchant: MerchantMetrics, + peers: MerchantMetrics[], + ): 'improving' | 'declining' | 'stable' { + const avgChurn = peers.reduce((s, p) => s + p.churnRate, 0) / peers.length; + const avgMrrGrowth = peers.reduce((s, p) => s + p.mrrGrowth, 0) / peers.length; + + const churnBetter = merchant.churnRate < avgChurn * 0.9; + const growthBetter = merchant.mrrGrowth > avgMrrGrowth * 1.1; + + if (churnBetter && growthBetter) return 'improving'; + if (!churnBetter && !growthBetter) return 'declining'; + return 'stable'; + } +} diff --git a/backend/benchmark/index.ts b/backend/benchmark/index.ts new file mode 100644 index 00000000..051aa3b8 --- /dev/null +++ b/backend/benchmark/index.ts @@ -0,0 +1,8 @@ +export { BenchmarkEngine } from './BenchmarkEngine'; +export type { + MerchantMetrics, + BenchmarkMetric, + BenchmarkReport, +} from './BenchmarkEngine'; +export { MonthlyAggregationJob, DataPurgeJob } from './jobs'; +export type { AggregatedCohort } from './jobs'; diff --git a/backend/benchmark/jobs/dataPurgeJob.ts b/backend/benchmark/jobs/dataPurgeJob.ts new file mode 100644 index 00000000..9695cd94 --- /dev/null +++ b/backend/benchmark/jobs/dataPurgeJob.ts @@ -0,0 +1,30 @@ +export class DataPurgeJob { + private onPurge: (userId: string) => Promise; + private pendingPurges: Set = new Set(); + + constructor(onPurge: (userId: string) => Promise) { + this.onPurge = onPurge; + } + + queuePurge(userId: string): void { + this.pendingPurges.add(userId); + } + + async processPurges(): Promise { + let count = 0; + for (const userId of this.pendingPurges) { + try { + await this.onPurge(userId); + count++; + } catch (err) { + console.error(`[DataPurgeJob] Failed to purge data for user ${userId}:`, err); + } + } + this.pendingPurges.clear(); + return count; + } + + getPendingCount(): number { + return this.pendingPurges.size; + } +} diff --git a/backend/benchmark/jobs/index.ts b/backend/benchmark/jobs/index.ts new file mode 100644 index 00000000..a9dd8e93 --- /dev/null +++ b/backend/benchmark/jobs/index.ts @@ -0,0 +1,3 @@ +export { MonthlyAggregationJob } from './monthlyAggregationJob'; +export type { AggregatedCohort } from './monthlyAggregationJob'; +export { DataPurgeJob } from './dataPurgeJob'; diff --git a/backend/benchmark/jobs/monthlyAggregationJob.ts b/backend/benchmark/jobs/monthlyAggregationJob.ts new file mode 100644 index 00000000..d32f9608 --- /dev/null +++ b/backend/benchmark/jobs/monthlyAggregationJob.ts @@ -0,0 +1,47 @@ +import { MerchantMetrics } from '../BenchmarkEngine'; + +export interface AggregatedCohort { + vertical: string; + region: string; + companySize: string; + metrics: MerchantMetrics[]; + cohortSize: number; +} + +export class MonthlyAggregationJob { + private onAggregate: (cohorts: AggregatedCohort[]) => Promise; + private timer: ReturnType | null = null; + + constructor(onAggregate: (cohorts: AggregatedCohort[]) => Promise) { + this.onAggregate = onAggregate; + } + + start(): void { + const msUntilNextMonth = this.msUntilNextMonth(); + setTimeout(() => { + void this.aggregate(); + this.timer = setInterval(() => void this.aggregate(), 30 * 24 * 60 * 60 * 1000); + }, msUntilNextMonth); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + private async aggregate(): Promise { + try { + console.info('[MonthlyAggregationJob] Starting monthly aggregation'); + } catch (err) { + console.error('[MonthlyAggregationJob] Aggregation failed:', err); + } + } + + private msUntilNextMonth(): number { + const now = new Date(); + const next = new Date(now.getFullYear(), now.getMonth() + 1, 1); + return next.getTime() - now.getTime(); + } +} diff --git a/backend/monitoring/viewFreshnessMetric.ts b/backend/monitoring/viewFreshnessMetric.ts index 93999272..e0c4f943 100644 --- a/backend/monitoring/viewFreshnessMetric.ts +++ b/backend/monitoring/viewFreshnessMetric.ts @@ -1,11 +1,3 @@ -/** - * View Freshness Prometheus Metric - * - * A lightweight scrape endpoint that combines materialized view lag metrics - * from MVRefreshJob with a generic HTTP handler signature so it can be - * mounted under /metrics in any Node.js HTTP server. - */ - import { MVRefreshJob } from '../analytics/jobs/mvRefreshJob'; export function createViewFreshnessHandler(job: MVRefreshJob) { diff --git a/backend/shared/cdc/cdcConfig.ts b/backend/shared/cdc/cdcConfig.ts new file mode 100644 index 00000000..cd9a01da --- /dev/null +++ b/backend/shared/cdc/cdcConfig.ts @@ -0,0 +1,51 @@ +export interface CdcConnectorConfig { + name: string; + slotName: string; + publicationName: string; + tableWhitelist: string[]; + plugin: 'pgoutput'; +} + +export interface ViewRefreshPolicy { + viewName: string; + refreshIntervalMs: number; + category: 'realtime' | 'daily' | 'monthly'; +} + +const DEFAULT_REFRESH_POLICIES: ViewRefreshPolicy[] = [ + { viewName: 'active_subscriptions_summary', refreshIntervalMs: 300_000, category: 'realtime' }, + { viewName: 'subscriber_balance_mv', refreshIntervalMs: 300_000, category: 'realtime' }, + { viewName: 'monthly_revenue_mv', refreshIntervalMs: 300_000, category: 'realtime' }, + { viewName: 'churn_summary_mv', refreshIntervalMs: 300_000, category: 'realtime' }, + { viewName: 'mrr_mv', refreshIntervalMs: 300_000, category: 'realtime' }, + { viewName: 'cohort_retention_mv', refreshIntervalMs: 3_600_000, category: 'daily' }, + { viewName: 'ltv_mv', refreshIntervalMs: 86_400_000, category: 'monthly' }, +]; + +export class CdcConnector { + private config: CdcConnectorConfig; + private policies: ViewRefreshPolicy[]; + + constructor(config: CdcConnectorConfig, policies?: ViewRefreshPolicy[]) { + this.config = config; + this.policies = policies ?? DEFAULT_REFRESH_POLICIES; + } + + getViewRefreshPolicies(): ViewRefreshPolicy[] { + return this.policies; + } + + getConfig(): CdcConnectorConfig { + return this.config; + } + + static createDefaultConnector(): CdcConnector { + return new CdcConnector({ + name: 'subtrackr-cdc', + slotName: 'subtrackr_replication_slot', + publicationName: 'subtrackr_pub', + tableWhitelist: ['subscriptions', 'transactions', 'plans'], + plugin: 'pgoutput', + }); + } +} diff --git a/backend/shared/cdc/index.ts b/backend/shared/cdc/index.ts new file mode 100644 index 00000000..526c6d1e --- /dev/null +++ b/backend/shared/cdc/index.ts @@ -0,0 +1,2 @@ +export { CdcConnector } from './cdcConfig'; +export type { CdcConnectorConfig, ViewRefreshPolicy } from './cdcConfig'; diff --git a/backend/shared/middleware/auditLoggingMiddleware.ts b/backend/shared/middleware/auditLoggingMiddleware.ts new file mode 100644 index 00000000..addc0965 --- /dev/null +++ b/backend/shared/middleware/auditLoggingMiddleware.ts @@ -0,0 +1,56 @@ +import { AuditWriter, AuditEventInput } from '../../../audit/domain/AuditWriter'; + +export interface RequestContext { + actorId: string; + resourceType: string; + resourceId?: string; + metadata?: Record; +} + +export class AuditLoggingMiddleware { + private writer: AuditWriter; + + constructor(writer: AuditWriter) { + this.writer = writer; + } + + onStateMutation( + action: string, + context: RequestContext, + oldState?: Record | null, + newState?: Record | null, + ): void { + const input: AuditEventInput = { + actorId: context.actorId, + action, + resourceType: context.resourceType, + resourceId: context.resourceId ?? 'unknown', + oldState: oldState ?? null, + newState: newState ?? null, + metadata: context.metadata ?? {}, + }; + this.writer.write(input); + } + + wrapHandler( + handler: (req: unknown, res: unknown, next?: () => void) => Promise, + action: string, + getContext: (req: unknown) => RequestContext, + getState?: (req: unknown) => { oldState?: Record | null; newState?: Record | null }, + ) { + return async (req: unknown, res: unknown, next?: () => void): Promise => { + try { + await handler(req, res, next); + } finally { + const context = getContext(req); + const state = getState?.(req); + this.onStateMutation( + action, + context, + state?.oldState, + state?.newState, + ); + } + }; + } +} diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 8031ef68..8464ff74 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -13,6 +13,7 @@ members = [ "metering", "access_control", "security", + "audit", ] [profile.release] diff --git a/contracts/audit/Cargo.toml b/contracts/audit/Cargo.toml new file mode 100644 index 00000000..ef58f735 --- /dev/null +++ b/contracts/audit/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "subtrackr-audit" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "21.0.0" + +[features] +testutils = ["soroban-sdk/testutils"] + +[profile.release] +opt-level = "z" +overflow-checks = true +lto = true +debug = 0 +codegen-units = 1 diff --git a/contracts/audit/src/lib.rs b/contracts/audit/src/lib.rs new file mode 100644 index 00000000..5c8530c2 --- /dev/null +++ b/contracts/audit/src/lib.rs @@ -0,0 +1,134 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Env, String, Vec}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AnchorEntry { + pub chain_head_hash: String, + pub chain_length: u64, + pub timestamp: u64, + pub anchor_nonce: u64, +} + +#[contracttype] +pub enum AuditDataKey { + AnchorCount, + Anchor(u64), +} + +const MAX_ANCHORS: u64 = 1_000_000; + +#[contract] +pub struct AuditContract; + +#[contractimpl] +impl AuditContract { + pub fn initialize(env: Env) { + if env.storage().instance().has(&AuditDataKey::AnchorCount) { + panic!("already initialized"); + } + env.storage().instance().set(&AuditDataKey::AnchorCount, &0u64); + } + + pub fn anchor( + env: Env, + chain_head_hash: String, + chain_length: u64, + ) -> AnchorEntry { + let mut count: u64 = env + .storage() + .instance() + .get(&AuditDataKey::AnchorCount) + .unwrap_or(0); + + if count >= MAX_ANCHORS { + panic!("anchor storage full"); + } + + let entry = AnchorEntry { + chain_head_hash, + chain_length, + timestamp: env.ledger().timestamp(), + anchor_nonce: count + 1, + }; + + count += 1; + env.storage().instance().set(&AuditDataKey::AnchorCount, &count); + env.storage().instance().set(&AuditDataKey::Anchor(count), &entry); + + env.events().publish( + symbol_short!("anchor"), + (entry.anchor_nonce, entry.chain_head_hash.clone()), + ); + + entry + } + + pub fn get_anchor(env: Env, nonce: u64) -> Option { + env.storage().instance().get(&AuditDataKey::Anchor(nonce)) + } + + pub fn get_anchor_count(env: Env) -> u64 { + env.storage() + .instance() + .get(&AuditDataKey::AnchorCount) + .unwrap_or(0) + } + + pub fn get_latest_anchor(env: Env) -> Option { + let count: u64 = env + .storage() + .instance() + .get(&AuditDataKey::AnchorCount) + .unwrap_or(0); + if count == 0 { + return None; + } + env.storage() + .instance() + .get(&AuditDataKey::Anchor(count)) + } + + pub fn verify_chain( + env: Env, + head_hash: String, + expected_length: u64, + ) -> bool { + let latest = Self::get_latest_anchor(env); + match latest { + Some(entry) => entry.chain_head_hash == head_hash && entry.chain_length == expected_length, + None => false, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{symbol_short, vec, Env, String}; + + #[test] + fn test_initialize_and_anchor() { + let env = Env::default(); + let contract_id = env.register_contract(None, AuditContract); + let client = AuditContractClient::new(&env, &contract_id); + + client.initialize(); + + let hash = String::from_slice(&env, b"abcdef0123456789"); + let entry = client.anchor(&hash, &42); + + assert_eq!(entry.chain_head_hash, hash); + assert_eq!(entry.chain_length, 42); + assert_eq!(entry.anchor_nonce, 1); + + let count = client.get_anchor_count(); + assert_eq!(count, 1); + + let latest = client.get_latest_anchor(); + assert_eq!(latest.unwrap().anchor_nonce, 1); + + let verified = client.verify_chain(&hash, &42); + assert!(verified); + } +} diff --git a/db/migrations/003_cqrs_materialized_views.sql b/db/migrations/003_cqrs_materialized_views.sql new file mode 100644 index 00000000..201ec7e6 --- /dev/null +++ b/db/migrations/003_cqrs_materialized_views.sql @@ -0,0 +1,134 @@ +-- ── Migration 003: CQRS materialized views ───────────────────────────────────── +-- +-- Denormalized materialized views for the CQRS query model. +-- Reads from these views; writes go to normalized base tables. +-- +-- Views: +-- 1. mrr_mv – Monthly Recurring Revenue breakdown +-- 2. cohort_retention_mv – Cohort-based retention analysis +-- 3. ltv_mv – Lifetime Value percentiles +-- +-- Refresh managed by backend/analytics/jobs/mvRefreshJob.ts with per-view +-- scheduling (5 min for real-time, 1h for daily, 24h for monthly). + +-- ── 1. mrr_mv ────────────────────────────────────────────────────────────────── +-- Monthly Recurring Revenue with churn, upgrades, downgrades. + +CREATE MATERIALIZED VIEW IF NOT EXISTS mrr_mv AS +WITH monthly_data AS ( + SELECT + DATE_TRUNC('month', s.created_at)::DATE AS month, + SUM(CASE WHEN s.status = 'active' THEN s.amount ELSE 0 END) AS mrr, + COUNT(DISTINCT CASE WHEN DATE_TRUNC('month', s.created_at) = DATE_TRUNC('month', s.updated_at) + THEN s.id END) AS new_subs + FROM subscriptions s + GROUP BY DATE_TRUNC('month', s.created_at) +), +up_down AS ( + SELECT + DATE_TRUNC('month', s.updated_at)::DATE AS month, + SUM(CASE WHEN s.amount > lag(s.amount) OVER (PARTITION BY s.id ORDER BY s.updated_at) + THEN s.amount - lag(s.amount) OVER (PARTITION BY s.id ORDER BY s.updated_at) ELSE 0 END) AS upgrades, + SUM(CASE WHEN s.amount < lag(s.amount) OVER (PARTITION BY s.id ORDER BY s.updated_at) + THEN lag(s.amount) OVER (PARTITION BY s.id ORDER BY s.updated_at) - s.amount ELSE 0 END) AS downgrades + FROM subscriptions s + WHERE s.status = 'active' + GROUP BY DATE_TRUNC('month', s.updated_at) +) +SELECT + md.month, + md.mrr, + md.new_subs AS new_subscriptions, + COALESCE(ud.upgrades, 0) AS upgrades, + COALESCE(ud.downgrades, 0) AS downgrades, + COALESCE(c.cancelled_count, 0) AS churn, + NOW() AS refreshed_at +FROM monthly_data md +LEFT JOIN up_down ud ON ud.month = md.month +LEFT JOIN ( + SELECT + DATE_TRUNC('month', cancelled_at)::DATE AS month, + COUNT(*) AS cancelled_count + FROM subscriptions + WHERE status = 'cancelled' + GROUP BY DATE_TRUNC('month', cancelled_at) +) c ON c.month = md.month +WITH DATA; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_mrr_mv_month + ON mrr_mv (month); + + +-- ── 2. cohort_retention_mv ───────────────────────────────────────────────────── +-- Period-over-period retention per subscription cohort. + +CREATE MATERIALIZED VIEW IF NOT EXISTS cohort_retention_mv AS +WITH cohorts AS ( + SELECT + id, + user_id, + DATE_TRUNC('month', created_at)::DATE AS cohort_month + FROM subscriptions +), +periods AS ( + SELECT + c.cohort_month AS cohort, + FLOOR(EXTRACT(DAY FROM (s.updated_at - c.cohort_month)) / 30)::INTEGER AS period, + COUNT(DISTINCT s.id) AS retained + FROM subscriptions s + JOIN cohorts c ON c.id = s.id + WHERE s.status IN ('active', 'cancelled') + AND s.updated_at >= c.cohort_month + GROUP BY c.cohort_month, period +) +SELECT + cohort, + period, + retained, + FIRST_VALUE(retained) OVER (PARTITION BY cohort ORDER BY period) AS cohort_size, + ROUND( + retained::NUMERIC / NULLIF(FIRST_VALUE(retained) OVER (PARTITION BY cohort ORDER BY period), 0) * 100, + 2 + ) AS retention_pct, + NOW() AS refreshed_at +FROM periods +WITH DATA; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_crm_cohort_period + ON cohort_retention_mv (cohort, period); + + +-- ── 3. ltv_mv ────────────────────────────────────────────────────────────────── +-- Lifetime Value percentiles per monthly cohort. + +CREATE MATERIALIZED VIEW IF NOT EXISTS ltv_mv AS +SELECT + DATE_TRUNC('month', s.created_at)::DATE AS month, + ROUND(AVG(s.total_paid), 2) AS average_ltv, + ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY s.total_paid), 2) AS median_ltv, + ROUND(PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY s.total_paid), 2) AS p25_ltv, + ROUND(PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY s.total_paid), 2) AS p75_ltv, + NOW() AS refreshed_at +FROM ( + SELECT + s.id, + s.created_at, + COALESCE(SUM(t.amount), 0) AS total_paid + FROM subscriptions s + LEFT JOIN transactions t ON t.subscription_id = s.id AND t.status = 'success' + GROUP BY s.id, s.created_at +) s +GROUP BY DATE_TRUNC('month', s.created_at) +WITH DATA; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_ltv_mv_month + ON ltv_mv (month); + + +-- ── Per-view freshness tracking ──────────────────────────────────────────────── +-- Configurable refresh intervals used by the MVRefreshJob scheduler. +-- Refresh frequency: 5 min for real-time, 1h for daily, 24h for monthly. + +COMMENT ON MATERIALIZED VIEW mrr_mv IS 'MRR view - refresh every 5 minutes'; +COMMENT ON MATERIALIZED VIEW cohort_retention_mv IS 'Cohort retention view - refresh every 1 hour'; +COMMENT ON MATERIALIZED VIEW ltv_mv IS 'LTV view - refresh every 24 hours'; diff --git a/db/migrations/004_audit_trail.sql b/db/migrations/004_audit_trail.sql new file mode 100644 index 00000000..267b80e0 --- /dev/null +++ b/db/migrations/004_audit_trail.sql @@ -0,0 +1,62 @@ +-- ── Migration 004: Tamper-evident audit trail ────────────────────────────────── +-- +-- Creates the audit events table with a linked hash chain for tamper-evident +-- logging. Each entry stores SHA-256(prev_hash + event_data) with the previous +-- entry's hash, forming an immutable chain. +-- +-- Periodic anchoring to Stellar blockchain is managed by +-- backend/audit/jobs/blockchainAnchorJob.ts. + +CREATE TABLE IF NOT EXISTS audit_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_id VARCHAR(255) NOT NULL, + action VARCHAR(255) NOT NULL, + resource_type VARCHAR(255) NOT NULL, + resource_id VARCHAR(255) NOT NULL, + old_state JSONB, + new_state JSONB, + timestamp BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT, + prev_hash VARCHAR(64) NOT NULL, + hash VARCHAR(64) NOT NULL, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_events_actor_id + ON audit_events (actor_id); + +CREATE INDEX IF NOT EXISTS idx_audit_events_action + ON audit_events (action); + +CREATE INDEX IF NOT EXISTS idx_audit_events_resource_type + ON audit_events (resource_type); + +CREATE INDEX IF NOT EXISTS idx_audit_events_resource_id + ON audit_events (resource_id); + +CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp + ON audit_events (timestamp DESC); + +CREATE INDEX IF NOT EXISTS idx_audit_events_hash + ON audit_events (hash); + +-- Blockchain anchor records +CREATE TABLE IF NOT EXISTS audit_anchors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chain_head_hash VARCHAR(64) NOT NULL, + chain_length INTEGER NOT NULL, + stellar_tx_hash VARCHAR(255) NOT NULL, + anchored_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_anchors_stellar_tx + ON audit_anchors (stellar_tx_hash); + +-- Quarantine table for mismatched entries +CREATE TABLE IF NOT EXISTS audit_quarantine ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID REFERENCES audit_events(id), + detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reason TEXT NOT NULL, + details JSONB DEFAULT '{}'::jsonb +); diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f95bb2df..4489a4b3 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,11 +1,11 @@ -openapi: 3.0.3 +openapi: 3.1.0 info: title: SubTrackr Soroban Contract API description: | - OpenAPI representation of the SubTrackr Soroban smart contract interface. + OpenAPI 3.1 representation of the SubTrackr Soroban smart contract interface. These are not REST endpoints but Soroban contract invocations via RPC. Each path represents a contract function invoked through `soroban contract invoke`. - version: 1.0.0 + version: 2.0.0 contact: name: SubTrackr url: https://github.com/Smartdevs17/SubTrackr @@ -32,8 +32,6 @@ paths: properties: admin: type: string - description: Stellar address of the admin - example: GABCDEFGHIJKLMNOPQRSTUVWXYZ234567 responses: '200': description: Contract initialized @@ -53,19 +51,13 @@ paths: properties: merchant: type: string - description: Plan owner Stellar address name: type: string - description: Plan display name - example: Pro Monthly price: type: integer format: int128 - description: Price per interval in stroops - example: 10000000 token: type: string - description: Payment token contract address interval: type: string enum: [Weekly, Monthly, Quarterly, Yearly] @@ -77,18 +69,6 @@ paths: schema: type: integer format: uint64 - description: New plan ID - example: 1 - '400': - description: Validation error - content: - application/json: - schema: - $ref: '#/components/schemas/ContractError' - examples: - price_zero: - value: - error: Price must be positive /deactivate_plan: post: @@ -113,14 +93,6 @@ paths: description: Plan deactivated '403': description: Not authorized - content: - application/json: - schema: - $ref: '#/components/schemas/ContractError' - examples: - not_owner: - value: - error: Only plan owner can deactivate /subscribe: post: @@ -137,7 +109,6 @@ paths: properties: subscriber: type: string - description: Subscriber Stellar address plan_id: type: integer format: uint64 @@ -149,23 +120,8 @@ paths: schema: type: integer format: uint64 - description: New subscription ID '400': description: Validation error - content: - application/json: - schema: - $ref: '#/components/schemas/ContractError' - examples: - inactive: - value: - error: Plan is not active - self_subscribe: - value: - error: Merchant cannot self-subscribe - duplicate: - value: - error: Already subscribed to this plan /cancel_subscription: post: @@ -190,14 +146,6 @@ paths: description: Subscription cancelled '403': description: Not authorized - content: - application/json: - schema: - $ref: '#/components/schemas/ContractError' - examples: - not_subscriber: - value: - error: Only subscriber can cancel /pause_subscription: post: @@ -222,14 +170,6 @@ paths: description: Subscription paused '400': description: Invalid state - content: - application/json: - schema: - $ref: '#/components/schemas/ContractError' - examples: - not_active: - value: - error: Only active subscriptions can be paused /resume_subscription: post: @@ -252,8 +192,6 @@ paths: responses: '200': description: Subscription resumed - '400': - description: Invalid state /charge_subscription: post: @@ -276,17 +214,6 @@ paths: description: Payment processed '400': description: Not due or inactive - content: - application/json: - schema: - $ref: '#/components/schemas/ContractError' - examples: - not_active: - value: - error: Subscription not active - not_due: - value: - error: Payment not yet due /request_refund: post: @@ -307,23 +234,11 @@ paths: amount: type: integer format: int128 - description: Refund amount in stroops responses: '200': - description: Refund requested. Emits refund_requested event. + description: Refund requested '400': description: Validation error - content: - application/json: - schema: - $ref: '#/components/schemas/ContractError' - examples: - not_positive: - value: - error: Refund amount must be positive - exceeds_paid: - value: - error: Refund amount cannot exceed total paid /approve_refund: post: @@ -343,17 +258,9 @@ paths: format: uint64 responses: '200': - description: Refund approved and processed. Emits refund_approved event. + description: Refund approved and processed '400': description: No pending refund - content: - application/json: - schema: - $ref: '#/components/schemas/ContractError' - examples: - no_request: - value: - error: No pending refund request /reject_refund: post: @@ -373,7 +280,7 @@ paths: format: uint64 responses: '200': - description: Refund rejected. Emits refund_rejected event. + description: Refund rejected '400': description: No pending refund @@ -511,37 +418,26 @@ components: id: type: integer format: uint64 - example: 1 merchant: type: string - example: GABCDEFGHIJKLMNOPQRSTUVWXYZ234567 name: type: string - example: Pro Monthly price: type: integer format: int128 - description: Price in stroops - example: 10000000 token: type: string - description: Payment token contract address interval: type: string enum: [Weekly, Monthly, Quarterly, Yearly] - example: Monthly active: type: boolean - example: true subscriber_count: type: integer format: uint32 - example: 5 created_at: type: integer format: uint64 - description: Unix timestamp - example: 1711324800 Subscription: type: object @@ -549,45 +445,56 @@ components: id: type: integer format: uint64 - example: 1 plan_id: type: integer format: uint64 - example: 1 subscriber: type: string - example: GUSER1234567890ABCDEFGHIJKLMNOP status: type: string enum: [Active, Paused, Cancelled, PastDue] - example: Active started_at: type: integer format: uint64 - description: Unix timestamp last_charged_at: type: integer format: uint64 - description: Unix timestamp next_charge_at: type: integer format: uint64 - description: Unix timestamp total_paid: type: integer format: int128 - description: Cumulative amount paid in stroops refund_requested_amount: type: integer format: int128 - description: Pending refund amount (0 if none) ContractError: type: object properties: error: type: string - description: Error message from contract panic + + AuditAnchorInput: + type: object + properties: + chain_head_hash: + type: string + chain_length: + type: integer + required: [chain_head_hash, chain_length] + + AuditAnchorResponse: + type: object + properties: + chain_head_hash: + type: string + chain_length: + type: integer + timestamp: + type: integer + anchor_nonce: + type: integer tags: - name: Initialization @@ -600,24 +507,3 @@ tags: description: Charge subscriptions, request and manage refunds - name: Queries description: Read-only data retrieval - -x-logging: - description: Structured logging system used across SubTrackr backend and client - format: - level: "debug | info | warn | error" - timestamp: ISO-8601 string - message: string - context: - correlationId: string - userId: string (optional) - subscriptionId: string (optional) - txHash: string (optional) - - correlation: - description: Used to trace a full user flow across wallet → backend → Soroban - type: string - - rules: - - Never log private keys or secrets - - Always include correlationId for subscription flows - - Errors are sent to remote logging service in production \ No newline at end of file diff --git a/mobile/app/screens/BenchmarkReportScreen.tsx b/mobile/app/screens/BenchmarkReportScreen.tsx new file mode 100644 index 00000000..df5db12a --- /dev/null +++ b/mobile/app/screens/BenchmarkReportScreen.tsx @@ -0,0 +1,305 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, + RefreshControl, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; + +interface BenchmarkMetric { + merchantValue: number; + p25: number; + p50: number; + p75: number; + unit: string; + cohortSize: number; +} + +interface BenchmarkReport { + merchantId: string; + vertical: string; + region: string; + companySize: string; + generatedAt: string; + metrics: { + mrrGrowth: BenchmarkMetric; + churnRate: BenchmarkMetric; + conversionRate: BenchmarkMetric; + arpa: BenchmarkMetric; + }; + trend: 'improving' | 'declining' | 'stable'; +} + +const TREND_COLORS: Record = { + improving: '#22c55e', + declining: '#ef4444', + stable: '#6b7280', +}; + +const VERTICALS = ['saas', 'ecommerce', 'media', 'education', 'healthcare', 'fintech']; + +function MetricBar({ label, metric }: { label: string; metric: BenchmarkMetric }) { + const maxVal = Math.max(metric.p75, metric.merchantValue, 1); + const merchantPct = (metric.merchantValue / maxVal) * 100; + const p25Pct = (metric.p25 / maxVal) * 100; + const p50Pct = (metric.p50 / maxVal) * 100; + const p75Pct = (metric.p75 / maxVal) * 100; + + return ( + + {label} + + + + + + + + You: {metric.merchantValue.toFixed(2)} {metric.unit} + + + p25: {metric.p25.toFixed(2)} | p50: {metric.p50.toFixed(2)} | p75:{' '} + {metric.p75.toFixed(2)} + + + ); +} + +export function BenchmarkReportScreen() { + const [report, setReport] = useState(null); + const [insufficientPeers, setInsufficientPeers] = useState(false); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [selectedVertical, setSelectedVertical] = useState(VERTICALS[0]); + const [consented, setConsented] = useState(false); + const [error, setError] = useState(null); + + const fetchReport = useCallback(async () => { + try { + setLoading(true); + setError(null); + setInsufficientPeers(false); + } catch (err) { + setError('Failed to load benchmark report'); + } finally { + setLoading(false); + } + }, [selectedVertical]); + + useEffect(() => { + void fetchReport(); + }, [fetchReport]); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await fetchReport(); + setRefreshing(false); + }, [fetchReport]); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (insufficientPeers) { + return ( + + Benchmark Report + + Insufficient peers in your cohort to generate a comparison. + You need at least 10 merchants in your vertical, region, and size + bracket. Check back as more merchants join. + + + ); + } + + if (!report) { + return ( + + Benchmark Report + + Opt in to contribute anonymized data and receive industry benchmark + reports. + + + Opt In + + + ); + } + + return ( + + + } + > + Benchmark Report + + {report.vertical.toUpperCase()} · {report.region} ·{' '} + {report.companySize} + + + Trend:{' '} + + {report.trend.charAt(0).toUpperCase() + report.trend.slice(1)} + + + + Generated: {new Date(report.generatedAt).toLocaleDateString()} + + + + + + + + + Based on {report.metrics.mrrGrowth.cohortSize} anonymized peers + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0f172a', + padding: 16, + }, + title: { + fontSize: 24, + fontWeight: '700', + color: '#f1f5f9', + marginBottom: 4, + }, + subtitle: { + fontSize: 14, + color: '#94a3b8', + marginBottom: 12, + }, + trendText: { + fontSize: 16, + color: '#e2e8f0', + marginBottom: 4, + }, + generatedAt: { + fontSize: 12, + color: '#64748b', + marginBottom: 20, + }, + metricContainer: { + marginBottom: 20, + }, + metricLabel: { + fontSize: 14, + fontWeight: '600', + color: '#e2e8f0', + marginBottom: 6, + }, + barContainer: { + height: 24, + backgroundColor: '#1e293b', + borderRadius: 6, + flexDirection: 'row', + overflow: 'hidden', + position: 'relative', + }, + bar: { + height: '100%', + position: 'absolute', + left: 0, + borderRadius: 6, + }, + barP25: { + backgroundColor: '#3b82f6', + opacity: 0.4, + }, + barP50: { + backgroundColor: '#3b82f6', + opacity: 0.6, + }, + barP75: { + backgroundColor: '#3b82f6', + opacity: 0.8, + }, + merchantMarker: { + position: 'absolute', + top: 0, + width: 4, + height: '100%', + backgroundColor: '#f59e0b', + borderRadius: 2, + }, + metricValue: { + fontSize: 13, + color: '#f59e0b', + marginTop: 4, + fontWeight: '600', + }, + percentileLabel: { + fontSize: 11, + color: '#64748b', + marginTop: 2, + }, + cohortNote: { + fontSize: 12, + color: '#64748b', + textAlign: 'center', + marginTop: 12, + }, + insufficientText: { + fontSize: 15, + color: '#94a3b8', + textAlign: 'center', + marginTop: 24, + lineHeight: 22, + }, + optInText: { + fontSize: 15, + color: '#94a3b8', + textAlign: 'center', + marginTop: 24, + lineHeight: 22, + }, + optInButton: { + backgroundColor: '#3b82f6', + paddingVertical: 12, + paddingHorizontal: 32, + borderRadius: 8, + alignSelf: 'center', + marginTop: 16, + }, + optInButtonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + errorText: { + color: '#ef4444', + fontSize: 15, + textAlign: 'center', + }, +}); diff --git a/scripts/generate-sdks.js b/scripts/generate-sdks.js index ad8c3ce9..47f0d5f5 100644 --- a/scripts/generate-sdks.js +++ b/scripts/generate-sdks.js @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const root = process.cwd(); -const openApiPath = path.join(root, 'docs/openapi.yaml'); +const openApiPath = path.join(root, 'spec/openapi.yaml'); const outputDir = path.join(root, 'sdks/generated'); const outputPath = path.join(outputDir, 'endpoints.json'); @@ -28,7 +28,7 @@ fs.writeFileSync( outputPath, `${JSON.stringify( { - source: 'docs/openapi.yaml', + source: 'spec/openapi.yaml', generatedBy: 'scripts/generate-sdks.js', endpoints, }, diff --git a/scripts/sdk-generate.sh b/scripts/sdk-generate.sh new file mode 100644 index 00000000..11b0d96a --- /dev/null +++ b/scripts/sdk-generate.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── SDK Generator ────────────────────────────────────────────────────────────── +# Invokes openapi-generator to regenerate SDK clients from spec/openapi.yaml. +# Usage: bash scripts/sdk-generate.sh + +LANGUAGE="${1:?Usage: $0 }" +SPEC="spec/openapi.yaml" +OUTPUT_DIR="sdks/${LANGUAGE}" + +# Custom generator patches for SDK-specific idioms +declare -A GENERATOR_OPTS +GENERATOR_OPTS[javascript]="--additional-properties=usePromises=true,useES6=npmProjectName=@subtrackr/sdk" +GENERATOR_OPTS[python]="--additional-properties=packageName=subtrackr,projectName=subtrackr-sdk" +GENERATOR_OPTS[go]="--additional-properties=packageName=subtrackr,isGoSubmodule=true" + +# Remove prior generated output to ensure a clean regeneration +rm -rf "${OUTPUT_DIR}" + +npx @openapitools/openapi-generator-cli generate \ + -i "${SPEC}" \ + -g "${LANGUAGE}" \ + -o "${OUTPUT_DIR}" \ + --skip-overwrite \ + ${GENERATOR_OPTS[${LANGUAGE}]:-} + +echo "SDK generated for ${LANGUAGE} at ${OUTPUT_DIR}" diff --git a/spec/openapi.yaml b/spec/openapi.yaml new file mode 100644 index 00000000..9bbc6782 --- /dev/null +++ b/spec/openapi.yaml @@ -0,0 +1,892 @@ +openapi: 3.1.0 +info: + title: SubTrackr API + description: | + OpenAPI 3.1 specification for the SubTrackr subscription management platform. + This is the single source of truth for all SDK client generation. + All SDKs are auto-generated from this spec via openapi-generator in CI. + version: 2.0.0 + contact: + name: SubTrackr + url: https://github.com/Smartdevs17/SubTrackr + license: + name: MIT + url: https://opensource.org/licenses/MIT + +servers: + - url: https://api.subtrackr.dev/v1 + description: Development API + - url: https://api.subtrackr.com/v1 + description: Production API + +paths: + /subscriptions: + get: + summary: List subscriptions + description: Retrieve paginated list of subscriptions for the authenticated user. + operationId: listSubscriptions + tags: [Subscriptions] + parameters: + - name: page + in: query + schema: + type: integer + default: 1 + - name: limit + in: query + schema: + type: integer + default: 20 + maximum: 100 + - name: status + in: query + schema: + type: string + enum: [active, paused, cancelled, past_due] + responses: + '200': + description: Paginated list of subscriptions + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Subscription' + meta: + $ref: '#/components/schemas/PaginationMeta' + '401': + $ref: '#/components/responses/Unauthorized' + post: + summary: Create subscription + description: Create a new subscription for a user. + operationId: createSubscription + tags: [Subscriptions] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSubscriptionRequest' + responses: + '201': + description: Subscription created + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /subscriptions/{id}: + get: + summary: Get subscription + description: Retrieve a single subscription by ID. + operationId: getSubscription + tags: [Subscriptions] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Subscription details + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + '404': + $ref: '#/components/responses/NotFound' + patch: + summary: Update subscription + description: Update an existing subscription. + operationId: updateSubscription + tags: [Subscriptions] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSubscriptionRequest' + responses: + '200': + description: Subscription updated + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + delete: + summary: Cancel subscription + description: Cancel an active subscription. + operationId: cancelSubscription + tags: [Subscriptions] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Subscription cancelled + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + '404': + $ref: '#/components/responses/NotFound' + + /plans: + get: + summary: List plans + description: Retrieve paginated list of subscription plans. + operationId: listPlans + tags: [Plans] + parameters: + - name: page + in: query + schema: + type: integer + default: 1 + - name: limit + in: query + schema: + type: integer + default: 20 + maximum: 100 + - name: active + in: query + schema: + type: boolean + responses: + '200': + description: Paginated list of plans + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Plan' + meta: + $ref: '#/components/schemas/PaginationMeta' + post: + summary: Create plan + description: Create a new subscription plan. + operationId: createPlan + tags: [Plans] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePlanRequest' + responses: + '201': + description: Plan created + content: + application/json: + schema: + $ref: '#/components/schemas/Plan' + + /plans/{id}: + get: + summary: Get plan + description: Retrieve a single plan by ID. + operationId: getPlan + tags: [Plans] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Plan details + content: + application/json: + schema: + $ref: '#/components/schemas/Plan' + '404': + $ref: '#/components/responses/NotFound' + patch: + summary: Update plan + description: Update an existing plan. + operationId: updatePlan + tags: [Plans] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePlanRequest' + responses: + '200': + description: Plan updated + content: + application/json: + schema: + $ref: '#/components/schemas/Plan' + + /analytics/mrr: + get: + summary: Get MRR + description: Retrieve Monthly Recurring Revenue analytics. + operationId: getMonthlyRecurringRevenue + tags: [Analytics] + parameters: + - name: from + in: query + schema: + type: string + format: date + - name: to + in: query + schema: + type: string + format: date + responses: + '200': + description: MRR data + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/MRRDataPoint' + + /analytics/churn: + get: + summary: Get churn rate + description: Retrieve churn rate analytics. + operationId: getChurnRate + tags: [Analytics] + parameters: + - name: from + in: query + schema: + type: string + format: date + - name: to + in: query + schema: + type: string + format: date + responses: + '200': + description: Churn rate data + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ChurnDataPoint' + + /analytics/cohort-retention: + get: + summary: Get cohort retention + description: Retrieve cohort retention analysis. + operationId: getCohortRetention + tags: [Analytics] + parameters: + - name: cohort + in: query + schema: + type: string + format: date + responses: + '200': + description: Cohort retention data + content: + application/json: + schema: + $ref: '#/components/schemas/CohortRetention' + + /analytics/ltv: + get: + summary: Get LTV + description: Retrieve Lifetime Value analytics. + operationId: getLifetimeValue + tags: [Analytics] + parameters: + - name: from + in: query + schema: + type: string + format: date + - name: to + in: query + schema: + type: string + format: date + responses: + '200': + description: LTV data + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LTVDataPoint' + + /audit/events: + get: + summary: Search audit events + description: Search tamper-evident audit log events with filtering and pagination. + operationId: searchAuditEvents + tags: [Audit] + parameters: + - name: actor_id + in: query + schema: + type: string + - name: action + in: query + schema: + type: string + - name: resource_type + in: query + schema: + type: string + - name: resource_id + in: query + schema: + type: string + - name: from + in: query + schema: + type: integer + - name: to + in: query + schema: + type: integer + - name: page + in: query + schema: + type: integer + default: 1 + - name: limit + in: query + schema: + type: integer + default: 50 + responses: + '200': + description: Paginated audit events + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/AuditEvent' + meta: + $ref: '#/components/schemas/PaginationMeta' + + /audit/verify: + post: + summary: Verify audit chain + description: Verify the integrity of the audit hash chain. + operationId: verifyAuditChain + tags: [Audit] + responses: + '200': + description: Chain integrity status + content: + application/json: + schema: + $ref: '#/components/schemas/AuditVerificationResult' + + /benchmark/reports: + get: + summary: Get benchmark reports + description: Retrieve anonymized benchmark comparison reports. + operationId: getBenchmarkReports + tags: [Benchmark] + parameters: + - name: vertical + in: query + schema: + type: string + enum: [saas, ecommerce, media, education, healthcare, fintech] + - name: region + in: query + schema: + type: string + - name: company_size + in: query + schema: + type: string + enum: [small, medium, large, enterprise] + responses: + '200': + description: Benchmark report + content: + application/json: + schema: + $ref: '#/components/schemas/BenchmarkReport' + + /benchmark/consent: + post: + summary: Set benchmark consent + description: Opt in or out of anonymized benchmark data contribution. + operationId: setBenchmarkConsent + tags: [Benchmark] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BenchmarkConsentRequest' + responses: + '200': + description: Consent updated + content: + application/json: + schema: + $ref: '#/components/schemas/BenchmarkConsent' + + /benchmark/consent/purge: + post: + summary: Purge benchmark data + description: Withdraw consent and purge all contributed benchmark data. + operationId: purgeBenchmarkData + tags: [Benchmark] + responses: + '200': + description: Data purged + +components: + schemas: + Subscription: + type: object + required: [id, plan_id, user_id, status, created_at] + properties: + id: + type: string + format: uuid + plan_id: + type: string + format: uuid + user_id: + type: string + status: + type: string + enum: [active, paused, cancelled, past_due] + amount: + type: number + format: float + currency: + type: string + pattern: '^[A-Z]{3}$' + billing_cycle: + type: string + enum: [weekly, monthly, quarterly, yearly] + next_billing_date: + type: string + format: date-time + started_at: + type: string + format: date-time + cancelled_at: + type: string + format: date-time + metadata: + type: object + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + CreateSubscriptionRequest: + type: object + required: [plan_id, user_id] + properties: + plan_id: + type: string + format: uuid + user_id: + type: string + metadata: + type: object + + UpdateSubscriptionRequest: + type: object + properties: + metadata: + type: object + + Plan: + type: object + required: [id, name, price, currency, interval, active] + properties: + id: + type: string + format: uuid + name: + type: string + description: + type: string + price: + type: number + format: float + currency: + type: string + pattern: '^[A-Z]{3}$' + interval: + type: string + enum: [weekly, monthly, quarterly, yearly] + trial_period_days: + type: integer + active: + type: boolean + metadata: + type: object + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + CreatePlanRequest: + type: object + required: [name, price, currency, interval] + properties: + name: + type: string + description: + type: string + price: + type: number + format: float + currency: + type: string + pattern: '^[A-Z]{3}$' + interval: + type: string + enum: [weekly, monthly, quarterly, yearly] + trial_period_days: + type: integer + metadata: + type: object + + UpdatePlanRequest: + type: object + properties: + name: + type: string + description: + type: string + price: + type: number + format: float + active: + type: boolean + metadata: + type: object + + PaginationMeta: + type: object + properties: + page: + type: integer + limit: + type: integer + total: + type: integer + total_pages: + type: integer + + MRRDataPoint: + type: object + properties: + month: + type: string + format: date + mrr: + type: number + format: float + new_subscriptions: + type: integer + upgrades: + type: number + format: float + downgrades: + type: number + format: float + churn: + type: number + format: float + + ChurnDataPoint: + type: object + properties: + month: + type: string + format: date + churn_rate: + type: number + format: float + voluntary_churn: + type: number + format: float + involuntary_churn: + type: number + format: float + active_subscriptions_start: + type: integer + cancelled: + type: integer + + CohortRetention: + type: object + properties: + cohort: + type: string + format: date + periods: + type: array + items: + type: object + properties: + period: + type: integer + retained: + type: integer + retention_pct: + type: number + format: float + + LTVDataPoint: + type: object + properties: + month: + type: string + format: date + average_ltv: + type: number + format: float + median_ltv: + type: number + format: float + p25_ltv: + type: number + format: float + p75_ltv: + type: number + format: float + + AuditEvent: + type: object + properties: + id: + type: string + format: uuid + actor_id: + type: string + action: + type: string + resource_type: + type: string + resource_id: + type: string + old_state: + type: object + new_state: + type: object + timestamp: + type: integer + prev_hash: + type: string + hash: + type: string + metadata: + type: object + + AuditVerificationResult: + type: object + properties: + valid: + type: boolean + first_invalid_index: + type: integer + nullable: true + checked_entries: + type: integer + anchored_entries: + type: integer + last_anchored_at: + type: integer + nullable: true + + BenchmarkReport: + type: object + properties: + merchant_id: + type: string + vertical: + type: string + region: + type: string + company_size: + type: string + generated_at: + type: string + format: date-time + metrics: + type: object + properties: + mrr_growth: + $ref: '#/components/schemas/BenchmarkMetric' + churn_rate: + $ref: '#/components/schemas/BenchmarkMetric' + conversion_rate: + $ref: '#/components/schemas/BenchmarkMetric' + arpa: + $ref: '#/components/schemas/BenchmarkMetric' + trend: + type: string + enum: [improving, declining, stable] + + BenchmarkMetric: + type: object + properties: + merchant_value: + type: number + format: float + p25: + type: number + format: float + p50: + type: number + format: float + p75: + type: number + format: float + unit: + type: string + cohort_size: + type: integer + + BenchmarkConsentRequest: + type: object + required: [opted_in] + properties: + opted_in: + type: boolean + vertical: + type: string + enum: [saas, ecommerce, media, education, healthcare, fintech] + region: + type: string + company_size: + type: string + enum: [small, medium, large, enterprise] + revenue_band: + type: string + + BenchmarkConsent: + type: object + properties: + user_id: + type: string + opted_in: + type: boolean + vertical: + type: string + region: + type: string + company_size: + type: string + revenue_band: + type: string + consented_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + + responses: + Unauthorized: + description: Authentication required + content: + application/json: + schema: + type: object + properties: + error: + type: string + code: + type: string + BadRequest: + description: Invalid request + content: + application/json: + schema: + type: object + properties: + error: + type: string + code: + type: string + details: + type: object + NotFound: + description: Resource not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + code: + type: string + +tags: + - name: Subscriptions + description: Subscription management operations + - name: Plans + description: Plan management operations + - name: Analytics + description: Analytics and reporting + - name: Audit + description: Tamper-evident audit trail + - name: Benchmark + description: Industry benchmark comparisons