diff --git a/backend/sandbox/controller/index.ts b/backend/sandbox/controller/index.ts new file mode 100644 index 00000000..30b3eef6 --- /dev/null +++ b/backend/sandbox/controller/index.ts @@ -0,0 +1 @@ +export { sandboxLifecycleRouter } from './sandboxLifecycleController'; diff --git a/backend/sandbox/controller/sandboxLifecycleController.ts b/backend/sandbox/controller/sandboxLifecycleController.ts new file mode 100644 index 00000000..9ce6816f --- /dev/null +++ b/backend/sandbox/controller/sandboxLifecycleController.ts @@ -0,0 +1,169 @@ +import { Request, Response, Router } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { + containerManager, + MaxSandboxesError, + TtlExtensionLimitError, +} from '../../../sandbox/orchestrator/containerManager'; +import { generateStellarKeypair } from '../utils/stellarUtils'; + +const router = Router(); + +export interface ProvisionRequest { + developerId: string; +} + +export interface ExtendTtlRequest { + sandboxId: string; +} + +/** + * POST /api/v1/sandbox/provision + * Provision a new ephemeral sandbox instance + */ +router.post('/provision', async (req: Request, res: Response) => { + try { + const { developerId } = req.body as ProvisionRequest; + + if (!developerId) { + return res.status(400).json({ error: 'developerId is required' }); + } + + const activeCount = containerManager.getActiveCount(); + if (activeCount >= 3) { + const waitTime = containerManager.estimateWaitTime(); + return res.status(429).json({ + error: 'Maximum concurrent sandboxes (3) reached', + estimatedWait: waitTime, + activeSandboxCount: activeCount, + maxConcurrent: 3, + }); + } + + const sandboxId = `sbx_${uuidv4().slice(0, 8)}`; + const keypair = generateStellarKeypair(); + const dbPassword = uuidv4().slice(0, 16); + + const status = await containerManager.provision({ + sandboxId, + developerId, + dbPassword, + dbHostPort: 0, + apiHostPort: 0, + stellarAccount: keypair.publicKey, + }); + + return res.status(201).json({ + sandboxId, + status: status.status, + createdAt: status.createdAt, + expiresAt: status.expiresAt, + stellarAccount: keypair.publicKey, + stellarSecret: keypair.secretKey, + endpoints: { + api: `https://sandbox-${sandboxId}.api.subtrackr.io`, + horizon: 'https://horizon-testnet.stellar.org', + }, + limits: { + ram: '512MB', + cpu: 1, + disk: '2GB', + ttl: '1 hour', + maxExtensions: 1, + }, + }); + } catch (err) { + if (err instanceof MaxSandboxesError) { + return res.status(429).json({ + error: err.message, + estimatedWait: err.estimatedWait, + maxConcurrent: err.maxConcurrent, + }); + } + console.error('Provision failed:', err); + return res.status(500).json({ error: 'Failed to provision sandbox' }); + } +}); + +/** + * DELETE /api/v1/sandbox/:sandboxId + * Tear down a sandbox instance + */ +router.delete('/:sandboxId', async (req: Request, res: Response) => { + try { + const { sandboxId } = req.params; + await containerManager.teardown(sandboxId); + return res.json({ message: `Sandbox ${sandboxId} torn down successfully` }); + } catch (err) { + console.error('Teardown failed:', err); + return res.status(500).json({ error: 'Failed to tear down sandbox' }); + } +}); + +/** + * POST /api/v1/sandbox/extend-ttl + * Extend sandbox TTL by 2 hours (one-time extension, max 4h total) + */ +router.post('/extend-ttl', async (req: Request, res: Response) => { + try { + const { sandboxId } = req.body as ExtendTtlRequest; + + if (!sandboxId) { + return res.status(400).json({ error: 'sandboxId is required' }); + } + + const extended = containerManager.extendTtl(sandboxId); + if (!extended) { + return res.status(404).json({ error: 'Sandbox not found' }); + } + + const status = containerManager.getStatus(sandboxId); + return res.json({ + message: `Sandbox ${sandboxId} TTL extended by 2 hours`, + newExpiry: status?.expiresAt, + }); + } catch (err) { + if (err instanceof TtlExtensionLimitError) { + return res.status(400).json({ error: err.message }); + } + console.error('Extend TTL failed:', err); + return res.status(500).json({ error: 'Failed to extend sandbox TTL' }); + } +}); + +/** + * GET /api/v1/sandbox/:sandboxId + * Get sandbox status + */ +router.get('/:sandboxId', (req: Request, res: Response) => { + const { sandboxId } = req.params; + const status = containerManager.getStatus(sandboxId); + + if (!status) { + return res.status(404).json({ error: 'Sandbox not found' }); + } + + return res.json(status); +}); + +/** + * GET /api/v1/sandbox/health/:sandboxId + * Health check - updates last activity timestamp + */ +router.post('/:sandboxId/touch', (req: Request, res: Response) => { + const { sandboxId } = req.params; + containerManager.touchActivity(sandboxId); + return res.json({ touched: true }); +}); + +/** + * POST /api/v1/sandbox/:sandboxId/extend-idle + * User confirms they want to keep the sandbox after idle warning + */ +router.post('/:sandboxId/extend-idle', (req: Request, res: Response) => { + const { sandboxId } = req.params; + containerManager.touchActivity(sandboxId); + return res.json({ message: 'Idle timer reset', sandboxId }); +}); + +export { router as sandboxLifecycleRouter }; diff --git a/backend/sandbox/package.json b/backend/sandbox/package.json new file mode 100644 index 00000000..c65d9982 --- /dev/null +++ b/backend/sandbox/package.json @@ -0,0 +1,17 @@ +{ + "name": "@subtrackr/backend-sandbox", + "version": "1.0.0", + "private": true, + "description": "Sandbox lifecycle REST API controller", + "main": "controller/index.ts", + "dependencies": { + "@subtrackr/sandbox-orchestrator": "1.0.0", + "@stellar/stellar-sdk": "^12.0.0", + "express": "^4.18.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@types/express": "^4.17.0", + "@types/uuid": "^9.0.0" + } +} diff --git a/backend/sandbox/utils/stellarUtils.ts b/backend/sandbox/utils/stellarUtils.ts new file mode 100644 index 00000000..de57ddd9 --- /dev/null +++ b/backend/sandbox/utils/stellarUtils.ts @@ -0,0 +1,23 @@ +import { Keypair } from '@stellar/stellar-sdk'; + +export interface StellarKeypair { + publicKey: string; + secretKey: string; +} + +export function generateStellarKeypair(): StellarKeypair { + const kp = Keypair.random(); + return { + publicKey: kp.publicKey(), + secretKey: kp.secret(), + }; +} + +export function isValidStellarPublicKey(key: string): boolean { + try { + Keypair.fromPublicKey(key); + return true; + } catch { + return false; + } +} diff --git a/developer-portal/pages/SandboxManagementPage.tsx b/developer-portal/pages/SandboxManagementPage.tsx new file mode 100644 index 00000000..9596bc52 --- /dev/null +++ b/developer-portal/pages/SandboxManagementPage.tsx @@ -0,0 +1,614 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, + ActivityIndicator, + RefreshControl, +} from 'react-native'; + +interface SandboxInstance { + sandboxId: string; + status: 'provisioning' | 'running' | 'stopped' | 'failed'; + createdAt: string; + expiresAt: string; + lastActivityAt: string; + ttlExtended?: boolean; + stellarAccount?: string; + endpoints?: { + api: string; + horizon: string; + }; +} + +interface SandboxManagementPageProps { + onNavigate: (page: string) => void; +} + +export const SandboxManagementPage: React.FC = ({ onNavigate }) => { + const [sandboxes, setSandboxes] = useState([]); + const [provisioning, setProvisioning] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + const activeCount = sandboxes.filter((s) => s.status === 'running').length; + + const loadSandboxes = useCallback(async () => { + const instances: SandboxInstance[] = []; + setSandboxes(instances); + }, []); + + useEffect(() => { + loadSandboxes(); + }, [loadSandboxes]); + + const onRefresh = async () => { + setRefreshing(true); + await loadSandboxes(); + setRefreshing(false); + }; + + const handleProvision = () => { + if (activeCount >= 3) { + Alert.alert( + 'Sandbox Limit Reached', + 'You already have 3 active sandboxes. Please wait for one to expire or tear one down before provisioning a new one.' + ); + return; + } + + Alert.alert( + 'Provision Sandbox', + 'This will spin up a new ephemeral sandbox instance with pre-seeded test data. ' + + 'The sandbox will auto-destroy after 1 hour (extendable to 4 hours). ' + + 'Resource limits: 512MB RAM, 1 CPU, 2GB disk.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Provision', + onPress: async () => { + setProvisioning(true); + try { + const mockSandbox: SandboxInstance = { + sandboxId: `sbx_${Date.now().toString(36)}`, + status: 'running', + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 3600000).toISOString(), + lastActivityAt: new Date().toISOString(), + ttlExtended: false, + stellarAccount: 'GBPLGZFOZSRG4X2LNHY7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7', + endpoints: { + api: `https://sandbox-${Date.now().toString(36)}.api.subtrackr.io`, + horizon: 'https://horizon-testnet.stellar.org', + }, + }; + setSandboxes((prev) => [...prev, mockSandbox]); + Alert.alert( + 'Sandbox Provisioned', + `Your sandbox (${mockSandbox.sandboxId}) is ready. ` + + 'It will auto-destroy in 1 hour. Check the dashboard for details.', + [{ text: 'OK' }] + ); + } catch { + Alert.alert('Error', 'Failed to provision sandbox. Please try again.'); + } finally { + setProvisioning(false); + } + }, + }, + ] + ); + }; + + const handleTeardown = (sandboxId: string) => { + Alert.alert( + 'Teardown Sandbox', + `This will permanently destroy sandbox ${sandboxId} and all its data. This cannot be undone.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Destroy', + style: 'destructive', + onPress: () => { + setSandboxes((prev) => prev.filter((s) => s.sandboxId !== sandboxId)); + Alert.alert('Destroyed', `Sandbox ${sandboxId} has been torn down.`); + }, + }, + ] + ); + }; + + const handleExtendTtl = (sandboxId: string, sandbox: SandboxInstance) => { + if (sandbox.ttlExtended) { + Alert.alert( + 'Extension Limit Reached', + 'This sandbox has already been extended once. Only one 2-hour extension is allowed per sandbox (max 4 hours total).' + ); + return; + } + + Alert.alert( + 'Extend TTL', + 'Extend sandbox lifetime by 2 hours? (Maximum total: 4 hours, one extension allowed.)', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Extend', + onPress: () => { + setSandboxes((prev) => + prev.map((s) => + s.sandboxId === sandboxId + ? { + ...s, + expiresAt: new Date( + Math.min( + Date.now() + 4 * 3600000, + new Date(s.expiresAt).getTime() + 2 * 3600000 + ) + ).toISOString(), + ttlExtended: true, + } + : s + ) + ); + Alert.alert('Extended', `Sandbox ${sandboxId} TTL extended by 2 hours.`); + }, + }, + ] + ); + }; + + const formatTimeRemaining = (expiresAt: string): string => { + const ms = new Date(expiresAt).getTime() - Date.now(); + if (ms <= 0) return 'Expired'; + const mins = Math.ceil(ms / 60000); + const h = Math.floor(mins / 60); + const m = mins % 60; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; + }; + + const getStatusColor = (status: string): string => { + switch (status) { + case 'running': + return '#22C55E'; + case 'provisioning': + return '#F59E0B'; + case 'failed': + return '#EF4444'; + default: + return '#6B7280'; + } + }; + + return ( + } + > + + Sandbox Management + + Ephemeral sandbox instances with pre-seeded test data. Each sandbox is fully isolated via + Docker and auto-destroys after the TTL. + + + + + + {activeCount} + Active + + + 3 + Max Concurrent + + + 512MB + RAM Limit + + + + = 3} + > + {provisioning ? ( + + ) : ( + + {activeCount >= 3 ? 'Max Sandboxes Reached' : '+ Provision Sandbox'} + + )} + + + {sandboxes.length === 0 && !provisioning && ( + + ๐Ÿ“ฆ + No Sandboxes + + Provision a sandbox to get started with pre-seeded test data on Stellar testnet. + + + )} + + {sandboxes.map((sb) => ( + + + + + {sb.sandboxId} + + + + {sb.status} + + + + + + + Time Remaining + + {formatTimeRemaining(sb.expiresAt)} + + + + Expires At + + {new Date(sb.expiresAt).toLocaleTimeString()} + + + {sb.stellarAccount && ( + + Stellar Account + + {sb.stellarAccount.slice(0, 12)}... + + + )} + {sb.endpoints && ( + + API Endpoint + + {sb.endpoints.api} + + + )} + + + + {sb.status === 'running' && ( + <> + handleExtendTtl(sb.sandboxId, sb)} + > + Extend TTL + + handleTeardown(sb.sandboxId)} + > + Destroy + + + )} + {sb.status === 'provisioning' && ( + + )} + + + ))} + + + Resource Limits + + + Memory + 512 MB + + + CPU + 1 core + + + Disk + 2 GB + + + Default TTL + 1 hour + + + Max TTL (after extension) + 4 hours + + + Max Concurrent + 3 + + + Idle Timeout + 30 min + + + + + + Pre-seeded Test Data + + Each sandbox comes with realistic test data on provision: + + + + ๐Ÿ“‹ + 5 sample plans + + + ๐Ÿ‘ค + 10 mock subscribers + + + ๐Ÿงพ + 20 sample invoices + + + โญ + Stellar testnet funding via friendbot + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F9FAFB', + }, + header: { + padding: 24, + backgroundColor: '#FFFFFF', + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + }, + title: { + fontSize: 28, + fontWeight: 'bold', + color: '#111827', + marginBottom: 8, + }, + subtitle: { + fontSize: 14, + color: '#6B7280', + lineHeight: 20, + }, + statsRow: { + flexDirection: 'row', + padding: 16, + gap: 12, + }, + statCard: { + flex: 1, + backgroundColor: '#FFFFFF', + borderRadius: 12, + padding: 14, + alignItems: 'center', + borderWidth: 1, + borderColor: '#E5E7EB', + }, + statValue: { + fontSize: 20, + fontWeight: '700', + color: '#111827', + marginBottom: 4, + }, + statLabel: { + fontSize: 12, + color: '#6B7280', + }, + provisionButton: { + backgroundColor: '#6366F1', + marginHorizontal: 16, + marginBottom: 16, + paddingVertical: 14, + borderRadius: 12, + alignItems: 'center', + }, + provisionButtonDisabled: { + backgroundColor: '#9CA3AF', + }, + provisionButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, + emptyState: { + alignItems: 'center', + paddingVertical: 40, + paddingHorizontal: 32, + }, + emptyIcon: { + fontSize: 48, + marginBottom: 12, + }, + emptyTitle: { + fontSize: 18, + fontWeight: '600', + color: '#111827', + marginBottom: 8, + }, + emptyDesc: { + fontSize: 14, + color: '#6B7280', + textAlign: 'center', + lineHeight: 20, + }, + sandboxCard: { + backgroundColor: '#FFFFFF', + marginHorizontal: 16, + marginBottom: 12, + borderRadius: 12, + borderWidth: 1, + borderColor: '#E5E7EB', + overflow: 'hidden', + }, + cardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 14, + borderBottomWidth: 1, + borderBottomColor: '#F3F4F6', + }, + cardTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + statusDot: { + width: 10, + height: 10, + borderRadius: 5, + }, + sandboxId: { + fontSize: 15, + fontWeight: '600', + color: '#111827', + fontFamily: 'monospace', + }, + statusBadge: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + }, + statusText: { + fontSize: 12, + fontWeight: '600', + textTransform: 'capitalize', + }, + cardBody: { + padding: 14, + gap: 10, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + infoLabel: { + fontSize: 13, + color: '#6B7280', + }, + infoValue: { + fontSize: 13, + fontWeight: '500', + color: '#111827', + }, + cardActions: { + flexDirection: 'row', + padding: 14, + paddingTop: 0, + gap: 10, + }, + extendButton: { + flex: 1, + backgroundColor: '#EEF2FF', + paddingVertical: 10, + borderRadius: 8, + alignItems: 'center', + }, + extendButtonText: { + color: '#6366F1', + fontSize: 13, + fontWeight: '600', + }, + teardownButton: { + flex: 1, + backgroundColor: '#FEF2F2', + paddingVertical: 10, + borderRadius: 8, + alignItems: 'center', + }, + teardownButtonText: { + color: '#EF4444', + fontSize: 13, + fontWeight: '600', + }, + section: { + backgroundColor: '#FFFFFF', + marginHorizontal: 16, + marginBottom: 16, + borderRadius: 12, + padding: 16, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + color: '#111827', + marginBottom: 8, + }, + sectionDesc: { + fontSize: 13, + color: '#6B7280', + marginBottom: 12, + }, + limitsTable: { + gap: 0, + }, + limitRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: '#F3F4F6', + }, + limitLabel: { + fontSize: 14, + color: '#6B7280', + }, + limitValue: { + fontSize: 14, + fontWeight: '500', + color: '#111827', + }, + seedList: { + gap: 10, + }, + seedItem: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + seedIcon: { + fontSize: 18, + }, + seedText: { + fontSize: 14, + color: '#374151', + }, + bottomSpacer: { + height: 40, + }, +}); diff --git a/developer-portal/pages/index.ts b/developer-portal/pages/index.ts index 88a060f5..eba82f09 100644 --- a/developer-portal/pages/index.ts +++ b/developer-portal/pages/index.ts @@ -5,3 +5,4 @@ export { UsagePage } from './UsagePage'; export { OnboardingPage } from './OnboardingPage'; export { MigrationPage } from './MigrationPage'; export { SandboxSettingsPage } from './SandboxSettingsPage'; +export { SandboxManagementPage } from './SandboxManagementPage'; diff --git a/sandbox/cleanup-worker/cleanupWorker.ts b/sandbox/cleanup-worker/cleanupWorker.ts new file mode 100644 index 00000000..16adc280 --- /dev/null +++ b/sandbox/cleanup-worker/cleanupWorker.ts @@ -0,0 +1,86 @@ +import { containerManager } from '../orchestrator/containerManager'; + +interface CleanupWorkerConfig { + checkIntervalMs?: number; + onIdleWarning?: (sandboxIds: string[]) => void; + onCleanup?: (sandboxIds: string[]) => void; +} + +export class CleanupWorker { + private timer: ReturnType | null = null; + private running = false; + private config: CleanupWorkerConfig; + + constructor(config?: Partial) { + this.config = { + checkIntervalMs: config?.checkIntervalMs ?? 60_000, + onIdleWarning: config?.onIdleWarning, + onCleanup: config?.onCleanup, + }; + } + + start(): void { + if (this.running) return; + this.running = true; + + console.log( + `[CleanupWorker] Started โ€” checking every ${this.config.checkIntervalMs}ms` + ); + + this.timer = setInterval(async () => { + await this.checkCycle(); + }, this.config.checkIntervalMs); + + this.checkCycle().catch((err) => + console.error('[CleanupWorker] Initial cycle failed:', err) + ); + } + + stop(): void { + if (!this.running || !this.timer) return; + clearInterval(this.timer); + this.timer = null; + this.running = false; + console.log('[CleanupWorker] Stopped'); + } + + isRunning(): boolean { + return this.running; + } + + private async checkCycle(): Promise { + try { + // 1. Check idle sandboxes โ€” warn users + const idleSandboxes = containerManager.checkIdleSandboxes(); + if (idleSandboxes.length > 0) { + console.log( + `[CleanupWorker] Idle warning for: ${idleSandboxes.join(', ')}` + ); + this.config.onIdleWarning?.(idleSandboxes); + } + + // 2. Check expired sandboxes โ€” teardown + const expiredSandboxes = containerManager.checkExpiredSandboxes(); + if (expiredSandboxes.length > 0) { + console.log( + `[CleanupWorker] Tearing down expired sandboxes: ${expiredSandboxes.join(', ')}` + ); + this.config.onCleanup?.(expiredSandboxes); + + for (const sandboxId of expiredSandboxes) { + try { + await containerManager.teardown(sandboxId); + console.log(`[CleanupWorker] Teardown complete: ${sandboxId}`); + } catch (err) { + console.error( + `[CleanupWorker] Teardown failed for ${sandboxId}:`, + err + ); + } + } + } + } catch (err) { + console.error('[CleanupWorker] Check cycle error:', err); + } + } +} diff --git a/sandbox/cleanup-worker/index.ts b/sandbox/cleanup-worker/index.ts new file mode 100644 index 00000000..e6b115fd --- /dev/null +++ b/sandbox/cleanup-worker/index.ts @@ -0,0 +1 @@ +export { CleanupWorker } from './cleanupWorker'; diff --git a/sandbox/cleanup-worker/package.json b/sandbox/cleanup-worker/package.json new file mode 100644 index 00000000..4f0e94e5 --- /dev/null +++ b/sandbox/cleanup-worker/package.json @@ -0,0 +1,10 @@ +{ + "name": "@subtrackr/sandbox-cleanup-worker", + "version": "1.0.0", + "private": true, + "description": "TTL checker and cleanup cron for sandbox instances", + "main": "index.ts", + "dependencies": { + "@subtrackr/sandbox-orchestrator": "1.0.0" + } +} diff --git a/sandbox/docker-compose.sandbox.yml b/sandbox/docker-compose.sandbox.yml new file mode 100644 index 00000000..08af6ea4 --- /dev/null +++ b/sandbox/docker-compose.sandbox.yml @@ -0,0 +1,59 @@ +# Sandbox stack template - placeholders are replaced by the orchestrator at provision time +version: "3.8" + +services: + db: + image: postgres:16-alpine + container_name: subtrackr_sandbox_{{SANDBOX_ID}}_db + environment: + POSTGRES_USER: subtrackr + POSTGRES_PASSWORD: {{DB_PASSWORD}} + POSTGRES_DB: subtrackr_sandbox + volumes: + - sandbox_data_{{SANDBOX_ID}}:/var/lib/postgresql/data + - ./seed/seed.sql:/docker-entrypoint-initdb.d/01-seed.sql + ports: + - "{{DB_HOST_PORT}}:5432" + networks: + - sandbox_net_{{SANDBOX_ID}} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U subtrackr -d subtrackr_sandbox"] + interval: 5s + timeout: 5s + retries: 10 + mem_limit: 256m + cpus: 0.5 + restart: unless-stopped + + api: + image: subtrackr/sandbox-api:latest + container_name: subtrackr_sandbox_{{SANDBOX_ID}}_api + environment: + NODE_ENV: sandbox + DATABASE_URL: postgres://subtrackr:{{DB_PASSWORD}}@db:5432/subtrackr_sandbox + SANDBOX_ID: "{{SANDBOX_ID}}" + STELLAR_NETWORK: testnet + STELLAR_HORIZON_URL: https://horizon-testnet.stellar.org + FRIENDBOT_URL: https://friendbot.stellar.org + STELLAR_SANDBOX_ACCOUNT: {{STELLAR_ACCOUNT}} + LOG_LEVEL: debug + IDLE_TIMEOUT_MINUTES: "30" + ports: + - "{{API_HOST_PORT}}:3000" + depends_on: + db: + condition: service_healthy + networks: + - sandbox_net_{{SANDBOX_ID}} + mem_limit: 256m + cpus: 0.5 + restart: unless-stopped + +networks: + sandbox_net_{{SANDBOX_ID}}: + driver: bridge + +volumes: + sandbox_data_{{SANDBOX_ID}}: + name: subtrackr_sandbox_data_{{SANDBOX_ID}} + driver: local diff --git a/sandbox/index.ts b/sandbox/index.ts index e5578cbf..b2e8a997 100644 --- a/sandbox/index.ts +++ b/sandbox/index.ts @@ -1,3 +1,14 @@ +// Orchestrator โ€” Docker-based ephemeral sandbox lifecycle +export { ContainerManager, containerManager, MaxSandboxesError, TtlExtensionLimitError } from './orchestrator/containerManager'; +export type { SandboxContainerSpec, SandboxContainerStatus } from './orchestrator/containerManager'; + +// Cleanup worker โ€” TTL checker and container teardown cron +export { CleanupWorker } from './cleanup-worker/cleanupWorker'; + +// Seed data runner +export { SeedRunner } from './seed/index'; +export type { SeedConfig } from './seed/index'; + export { SandboxService, sandboxService } from './services/sandboxService'; export { SandboxIsolationService } from './services/sandboxIsolationService'; export { ApiKeyService } from './services/apiKeyService'; diff --git a/sandbox/orchestrator/containerManager.ts b/sandbox/orchestrator/containerManager.ts new file mode 100644 index 00000000..99e1f684 --- /dev/null +++ b/sandbox/orchestrator/containerManager.ts @@ -0,0 +1,299 @@ +import Docker from 'dockerode'; +import { EventEmitter } from 'events'; + +export interface SandboxContainerSpec { + sandboxId: string; + developerId: string; + dbPassword: string; + dbHostPort: number; + apiHostPort: number; + stellarAccount: string; +} + +export interface SandboxContainerStatus { + sandboxId: string; + developerId: string; + containerIds: string[]; + status: 'provisioning' | 'running' | 'stopped' | 'failed'; + createdAt: Date; + expiresAt: Date; + lastActivityAt: Date; + idleWarningSent: boolean; + ttlExtended: boolean; +} + +export class ContainerManager extends EventEmitter { + private docker: Docker; + private containers: Map = new Map(); + private readonly MAX_CONCURRENT = 3; + private readonly DEFAULT_TTL_MS = 60 * 60 * 1000; + private readonly MAX_TTL_MS = 4 * 60 * 60 * 1000; + private readonly EXTENSION_MS = 2 * 60 * 60 * 1000; + private readonly IDLE_TIMEOUT_MS = 30 * 60 * 1000; + private readonly COMPOSE_TEMPLATE_PATH = '../docker-compose.sandbox.yml'; + + constructor() { + super(); + this.docker = new Docker(); + } + + async provision(spec: SandboxContainerSpec): Promise { + const activeCount = this.getActiveCount(); + if (activeCount >= this.MAX_CONCURRENT) { + const waitTime = this.estimateWaitTime(); + throw new MaxSandboxesError(this.MAX_CONCURRENT, waitTime); + } + + const now = new Date(); + const status: SandboxContainerStatus = { + sandboxId: spec.sandboxId, + developerId: spec.developerId, + containerIds: [], + status: 'provisioning', + createdAt: now, + expiresAt: new Date(now.getTime() + this.DEFAULT_TTL_MS), + lastActivityAt: now, + idleWarningSent: false, + ttlExtended: false, + }; + + this.containers.set(spec.sandboxId, status); + + try { + const composeContent = await this.renderComposeTemplate(spec); + const composeFile = `docker-compose-${spec.sandboxId}.yml`; + await this.writeComposeFile(composeFile, composeContent); + + await this.runDockerComposeUp(composeFile); + + const containerIds = await this.resolveContainerIds(spec.sandboxId); + status.containerIds = containerIds; + status.status = 'running'; + + await this.fundStellarAccount(spec.stellarAccount); + + this.emit('sandbox:provisioned', { sandboxId: spec.sandboxId, status }); + return status; + } catch (err) { + status.status = 'failed'; + this.emit('sandbox:failed', { sandboxId: spec.sandboxId, error: err }); + throw err; + } + } + + async teardown(sandboxId: string): Promise { + const status = this.containers.get(sandboxId); + if (!status) return; + + try { + const composeFile = `docker-compose-${sandboxId}.yml`; + await this.runDockerComposeDown(composeFile); + await this.removeVolume(sandboxId); + await this.cleanupComposeFile(composeFile); + } finally { + this.containers.delete(sandboxId); + this.emit('sandbox:removed', { sandboxId }); + } + } + + extendTtl(sandboxId: string): boolean { + const status = this.containers.get(sandboxId); + if (!status) return false; + + if (status.ttlExtended) { + throw new TtlExtensionLimitError(); + } + + const now = Date.now(); + const remainingTtl = Math.max(0, status.expiresAt.getTime() - now); + const newTtl = Math.min(remainingTtl + this.EXTENSION_MS, this.MAX_TTL_MS); + + status.expiresAt = new Date(now + newTtl); + status.ttlExtended = true; + this.emit('sandbox:extended', { sandboxId, newExpiry: status.expiresAt }); + return true; + } + + touchActivity(sandboxId: string): void { + const status = this.containers.get(sandboxId); + if (status) { + status.lastActivityAt = new Date(); + status.idleWarningSent = false; + } + } + + getStatus(sandboxId: string): SandboxContainerStatus | undefined { + return this.containers.get(sandboxId); + } + + listByDeveloper(developerId: string): SandboxContainerStatus[] { + return Array.from(this.containers.values()).filter( + (s) => s.developerId === developerId + ); + } + + getActiveCount(): number { + let count = 0; + for (const s of this.containers.values()) { + if (s.status === 'running' || s.status === 'provisioning') count++; + } + return count; + } + + estimateWaitTime(): string { + const earliestExpiry = Array.from(this.containers.values()) + .filter((s) => s.status === 'running') + .sort((a, b) => a.expiresAt.getTime() - b.expiresAt.getTime()); + + if (earliestExpiry.length === 0) return 'unknown'; + + const ms = Math.max(0, earliestExpiry[0].expiresAt.getTime() - Date.now()); + const minutes = Math.ceil(ms / 60000); + if (minutes < 1) return 'less than a minute'; + return `~${minutes} minute${minutes !== 1 ? 's' : ''}`; + } + + checkIdleSandboxes(): string[] { + const now = Date.now(); + const warned: string[] = []; + + for (const [sandboxId, status] of this.containers.entries()) { + if (status.status !== 'running') continue; + + const idleMs = now - status.lastActivityAt.getTime(); + if (idleMs >= this.IDLE_TIMEOUT_MS && !status.idleWarningSent) { + status.idleWarningSent = true; + warned.push(sandboxId); + } + } + + return warned; + } + + checkExpiredSandboxes(): string[] { + const now = Date.now(); + const expired: string[] = []; + + for (const [sandboxId, status] of this.containers.entries()) { + if (status.expiresAt.getTime() <= now) { + expired.push(sandboxId); + } + } + + return expired; + } + + private async renderComposeTemplate(spec: SandboxContainerSpec): Promise { + const fs = await import('fs/promises'); + const path = await import('path'); + const templatePath = path.resolve(__dirname, this.COMPOSE_TEMPLATE_PATH); + let content = await fs.readFile(templatePath, 'utf-8'); + + const replacements: Record = { + '{{SANDBOX_ID}}': spec.sandboxId, + '{{DB_PASSWORD}}': spec.dbPassword, + '{{DB_HOST_PORT}}': String(spec.dbHostPort), + '{{API_HOST_PORT}}': String(spec.apiHostPort), + '{{STELLAR_ACCOUNT}}': spec.stellarAccount, + }; + + for (const [key, value] of Object.entries(replacements)) { + content = content.replaceAll(key, value); + } + + return content; + } + + private async writeComposeFile(filename: string, content: string): Promise { + const fs = await import('fs/promises'); + const path = await import('path'); + const filePath = path.resolve(__dirname, filename); + await fs.writeFile(filePath, content, 'utf-8'); + } + + private async runDockerComposeUp(composeFile: string): Promise { + const { execSync } = await import('child_process'); + const path = await import('path'); + const filePath = path.resolve(__dirname, composeFile); + execSync(`docker-compose -f "${filePath}" up -d --wait`, { + stdio: 'pipe', + timeout: 120000, + }); + } + + private async runDockerComposeDown(composeFile: string): Promise { + const { execSync } = await import('child_process'); + const path = await import('path'); + const filePath = path.resolve(__dirname, composeFile); + execSync(`docker-compose -f "${filePath}" down -v`, { + stdio: 'pipe', + timeout: 60000, + }); + } + + private async removeVolume(sandboxId: string): Promise { + const { execSync } = await import('child_process'); + const volumeName = `subtrackr_sandbox_data_${sandboxId}`; + try { + execSync(`docker volume rm "${volumeName}" --force`, { stdio: 'pipe', timeout: 30000 }); + } catch { + } + } + + private async cleanupComposeFile(filename: string): Promise { + const fs = await import('fs/promises'); + const path = await import('path'); + const filePath = path.resolve(__dirname, filename); + try { + await fs.unlink(filePath); + } catch { + } + } + + private async resolveContainerIds(sandboxId: string): Promise { + const { execSync } = await import('child_process'); + const output = execSync( + `docker ps --filter "name=subtrackr_sandbox_${sandboxId}" --format "{{.ID}}"`, + { encoding: 'utf-8', timeout: 10000 } + ); + return output.trim().split('\n').filter(Boolean); + } + + private async fundStellarAccount(publicKey: string): Promise { + const response = await fetch( + `https://friendbot.stellar.org?addr=${publicKey}`, + { method: 'GET' } + ); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Stellar friendbot funding failed: ${text}`); + } + } +} + +export class MaxSandboxesError extends Error { + public readonly maxConcurrent: number; + public readonly estimatedWait: string; + + constructor(maxConcurrent: number, estimatedWait: string) { + super( + `Maximum concurrent sandboxes (${maxConcurrent}) reached. ` + + `Estimated wait time: ${estimatedWait}.` + ); + this.name = 'MaxSandboxesError'; + this.maxConcurrent = maxConcurrent; + this.estimatedWait = estimatedWait; + } +} + +export class TtlExtensionLimitError extends Error { + constructor() { + super( + 'TTL extension limit reached. Sandboxes can only be extended once by up to 2 hours, ' + + 'for a maximum total lifetime of 4 hours.' + ); + this.name = 'TtlExtensionLimitError'; + } +} + +export const containerManager = new ContainerManager(); diff --git a/sandbox/orchestrator/index.ts b/sandbox/orchestrator/index.ts new file mode 100644 index 00000000..d871d377 --- /dev/null +++ b/sandbox/orchestrator/index.ts @@ -0,0 +1,7 @@ +export { + ContainerManager, + containerManager, + MaxSandboxesError, + TtlExtensionLimitError, +} from './containerManager'; +export type { SandboxContainerSpec, SandboxContainerStatus } from './containerManager'; diff --git a/sandbox/orchestrator/package.json b/sandbox/orchestrator/package.json new file mode 100644 index 00000000..994cc1bb --- /dev/null +++ b/sandbox/orchestrator/package.json @@ -0,0 +1,15 @@ +{ + "name": "@subtrackr/sandbox-orchestrator", + "version": "1.0.0", + "private": true, + "description": "Docker API orchestration service for ephemeral sandbox instances", + "main": "index.ts", + "dependencies": { + "dockerode": "^4.0.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@types/dockerode": "^4.0.0", + "@types/uuid": "^9.0.0" + } +} diff --git a/sandbox/seed/deployContracts.sh b/sandbox/seed/deployContracts.sh new file mode 100644 index 00000000..d4fd8710 --- /dev/null +++ b/sandbox/seed/deployContracts.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Deploy Stellar testnet contracts for the sandbox instance +# Called by the orchestrator after container provisioning + +set -euo pipefail + +SANDBOX_ID="${1:-}" +STELLAR_ACCOUNT="${2:-}" + +if [ -z "$SANDBOX_ID" ] || [ -z "$STELLAR_ACCOUNT" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Deploying contracts for sandbox $SANDBOX_ID using account $STELLAR_ACCOUNT" + +CONTRACTS_DIR="/workspace/contracts" + +# Build contracts +echo "Building contracts..." +cd "$CONTRACTS_DIR" +cargo build --release 2>&1 + +# Deploy subscription contract +echo "Deploying subscription contract..." +stellar contract deploy \ + --wasm target/release/subtrackr_subscription.wasm \ + --source "$STELLAR_ACCOUNT" \ + --network testnet \ + --alias "subscription_${SANDBOX_ID}" 2>&1 + +# Deploy billing contract +echo "Deploying billing contract..." +stellar contract deploy \ + --wasm target/release/subtrackr_billing.wasm \ + --source "$STELLAR_ACCOUNT" \ + --network testnet \ + --alias "billing_${SANDBOX_ID}" 2>&1 + +echo "Contracts deployed successfully for sandbox $SANDBOX_ID" diff --git a/sandbox/seed/index.ts b/sandbox/seed/index.ts new file mode 100644 index 00000000..6d51ed24 --- /dev/null +++ b/sandbox/seed/index.ts @@ -0,0 +1,58 @@ +import { execSync } from 'child_process'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +export interface SeedConfig { + dbHost: string; + dbPort: number; + dbUser: string; + dbPassword: string; + dbName: string; +} + +export class SeedRunner { + constructor(private config: SeedConfig) {} + + async runSeed(): Promise { + const seedPath = resolve(__dirname, 'seed.sql'); + const sql = readFileSync(seedPath, 'utf-8'); + + const { Pool } = await import('pg'); + const pool = new Pool({ + host: this.config.dbHost, + port: this.config.dbPort, + user: this.config.dbUser, + password: this.config.dbPassword, + database: this.config.dbName, + }); + + try { + await pool.query(sql); + console.log(`Seed data applied successfully for ${this.config.dbName}`); + } catch (err) { + if (err instanceof Error && err.message.includes('already exists')) { + console.log('Seed data already present, skipping.'); + } else { + throw err; + } + } finally { + await pool.end(); + } + } + + static runSeedFromEnv(): void { + const config: SeedConfig = { + dbHost: process.env.DB_HOST || 'localhost', + dbPort: parseInt(process.env.DB_PORT || '5432', 10), + dbUser: process.env.DB_USER || 'subtrackr', + dbPassword: process.env.DB_PASSWORD || '', + dbName: process.env.DB_NAME || 'subtrackr_sandbox', + }; + + const runner = new SeedRunner(config); + runner.runSeed().catch((err) => { + console.error('Seed failed:', err); + process.exit(1); + }); + } +} diff --git a/sandbox/seed/seed.sql b/sandbox/seed/seed.sql new file mode 100644 index 00000000..f7dc9d1f --- /dev/null +++ b/sandbox/seed/seed.sql @@ -0,0 +1,67 @@ +-- Sandbox seed data: 5 plans, 10 subscribers, 20 invoices +-- This is run automatically on first DB init for each sandbox instance + +-- Plans +INSERT INTO plans (id, name, description, amount, currency, interval, features, is_active, created_at) +VALUES + ('plan_basic', 'Basic', 'Essential subscription features', 9.99, 'USD', 'monthly', '["core_api","basic_analytics"]'::jsonb, true, NOW()), + ('plan_pro', 'Pro', 'Advanced features for growing teams', 29.99, 'USD', 'monthly', '["core_api","advanced_analytics","webhooks","team"]'::jsonb, true, NOW()), + ('plan_enterprise','Enterprise', 'Full platform with dedicated support', 99.99, 'USD', 'monthly', '["core_api","advanced_analytics","webhooks","team","sla","audit_log"]'::jsonb, true, NOW()), + ('plan_starter', 'Starter', 'Best for freelancers and side projects', 4.99, 'USD', 'monthly', '["core_api"]'::jsonb, true, NOW()), + ('plan_premium', 'Premium', 'Premium tier with crypto payments', 49.99, 'USD', 'monthly', '["core_api","advanced_analytics","crypto_payments","priority_support"]'::jsonb, true, NOW()) +ON CONFLICT (id) DO NOTHING; + +-- Subscribers (10 mock users) +INSERT INTO subscribers (id, email, name, wallet_address, stellar_account, status, created_at) +VALUES + ('sub_001', 'alice@example.com', 'Alice Johnson', '0x1234567890abcdef1234567890abcdef12345678', 'GBPLGZFOZSRG4X2LNHY7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X', 'active', NOW() - INTERVAL '60 days'), + ('sub_002', 'bob@example.com', 'Bob Smith', '0x2345678901abcdef2345678901abcdef23456789', 'GBPLGZFOZSRG4X2LNHY7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7Y', 'active', NOW() - INTERVAL '45 days'), + ('sub_003', 'carol@example.com', 'Carol Davis', '0x3456789012abcdef3456789012abcdef34567890', 'GBPLGZFOZSRG4X2LNHY7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7Z', 'active', NOW() - INTERVAL '30 days'), + ('sub_004', 'dave@example.com', 'Dave Wilson', '0x4567890123abcdef4567890123abcdef45678901', 'GBPLGZFOZSRG4X2LNHY7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y80A', 'paused', NOW() - INTERVAL '20 days'), + ('sub_005', 'eve@example.com', 'Eve Martin', '0x5678901234abcdef5678901234abcdef56789012', 'GBPLGZFOZSRG4X2LNHY7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y81B', 'active', NOW() - INTERVAL '15 days'), + ('sub_006', 'frank@example.com', 'Frank Lee', '0x6789012345abcdef6789012345abcdef67890123', 'GBPLGZFOZSRG4X2LNHY7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y82C', 'active', NOW() - INTERVAL '10 days'), + ('sub_007', 'grace@example.com', 'Grace Kim', '0x7890123456abcdef7890123456abcdef78901234', 'GBPLGZFOZSRG4X2LNHY7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y83D', 'cancelled', NOW() - INTERVAL '90 days'), + ('sub_008', 'hank@example.com', 'Hank Brown', '0x8901234567abcdef8901234567abcdef89012345', 'GBPLGZFOZSRG4X2LNHY7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y84E', 'active', NOW() - INTERVAL '5 days'), + ('sub_009', 'iris@example.com', 'Iris Chen', '0x9012345678abcdef9012345678abcdef90123456', 'GBPLGZFOZSRG4X2LNHY7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y85F', 'active', NOW() - INTERVAL '3 days'), + ('sub_010', 'jack@example.com', 'Jack Taylor', '0x0123456789abcdef0123456789abcdef01234567', 'GBPLGZFOZSRG4X2LNHY7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y7X7Y86G', 'active', NOW() - INTERVAL '1 day') +ON CONFLICT (id) DO NOTHING; + +-- Subscriptions (linking subscribers to plans) +INSERT INTO subscriptions (id, subscriber_id, plan_id, status, current_period_start, current_period_end, created_at) +VALUES + ('sbs_001', 'sub_001', 'plan_pro', 'active', NOW() - INTERVAL '30 days', NOW() + INTERVAL '20 days', NOW() - INTERVAL '30 days'), + ('sbs_002', 'sub_002', 'plan_basic', 'active', NOW() - INTERVAL '30 days', NOW() + INTERVAL '5 days', NOW() - INTERVAL '30 days'), + ('sbs_003', 'sub_003', 'plan_enterprise','active', NOW() - INTERVAL '30 days', NOW() + INTERVAL '12 days', NOW() - INTERVAL '30 days'), + ('sbs_004', 'sub_004', 'plan_starter', 'paused', NOW() - INTERVAL '30 days', NOW() - INTERVAL '2 days', NOW() - INTERVAL '30 days'), + ('sbs_005', 'sub_005', 'plan_premium', 'active', NOW() - INTERVAL '30 days', NOW() + INTERVAL '25 days', NOW() - INTERVAL '30 days'), + ('sbs_006', 'sub_006', 'plan_pro', 'active', NOW() - INTERVAL '15 days', NOW() + INTERVAL '10 days', NOW() - INTERVAL '15 days'), + ('sbs_007', 'sub_007', 'plan_basic', 'cancelled', NOW() - INTERVAL '90 days', NOW() - INTERVAL '60 days', NOW() - INTERVAL '90 days'), + ('sbs_008', 'sub_008', 'plan_starter', 'active', NOW() - INTERVAL '5 days', NOW() + INTERVAL '25 days', NOW() - INTERVAL '5 days'), + ('sbs_009', 'sub_009', 'plan_pro', 'active', NOW() - INTERVAL '3 days', NOW() + INTERVAL '20 days', NOW() - INTERVAL '3 days'), + ('sbs_010', 'sub_010', 'plan_premium', 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '28 days', NOW() - INTERVAL '1 day') +ON CONFLICT (id) DO NOTHING; + +-- Invoices (20 sample invoices across subscriptions) +INSERT INTO invoices (id, subscription_id, subscriber_id, amount, currency, status, due_date, paid_at, created_at) +VALUES + ('inv_001', 'sbs_001', 'sub_001', 29.99, 'USD', 'paid', NOW() - INTERVAL '30 days', NOW() - INTERVAL '28 days', NOW() - INTERVAL '35 days'), + ('inv_002', 'sbs_001', 'sub_001', 29.99, 'USD', 'paid', NOW() - INTERVAL '0 days', NULL, NOW() - INTERVAL '5 days'), + ('inv_003', 'sbs_002', 'sub_002', 9.99, 'USD', 'paid', NOW() - INTERVAL '25 days', NOW() - INTERVAL '23 days', NOW() - INTERVAL '30 days'), + ('inv_004', 'sbs_002', 'sub_002', 9.99, 'USD', 'pending', NOW() + INTERVAL '5 days', NULL, NOW()), + ('inv_005', 'sbs_003', 'sub_003', 99.99, 'USD', 'paid', NOW() - INTERVAL '20 days', NOW() - INTERVAL '18 days', NOW() - INTERVAL '25 days'), + ('inv_006', 'sbs_003', 'sub_003', 99.99, 'USD', 'paid', NOW() - INTERVAL '0 days', NOW() - INTERVAL '1 days', NOW() - INTERVAL '5 days'), + ('inv_007', 'sbs_004', 'sub_004', 4.99, 'USD', 'overdue', NOW() - INTERVAL '2 days', NULL, NOW() - INTERVAL '32 days'), + ('inv_008', 'sbs_005', 'sub_005', 49.99, 'USD', 'paid', NOW() - INTERVAL '15 days', NOW() - INTERVAL '13 days', NOW() - INTERVAL '20 days'), + ('inv_009', 'sbs_005', 'sub_005', 49.99, 'USD', 'paid', NOW() + INTERVAL '15 days', NULL, NOW()), + ('inv_010', 'sbs_006', 'sub_006', 29.99, 'USD', 'paid', NOW() - INTERVAL '10 days', NOW() - INTERVAL '8 days', NOW() - INTERVAL '15 days'), + ('inv_011', 'sbs_006', 'sub_006', 29.99, 'USD', 'pending', NOW() + INTERVAL '10 days', NULL, NOW()), + ('inv_012', 'sbs_007', 'sub_007', 9.99, 'USD', 'cancelled', NOW() - INTERVAL '90 days', NOW() - INTERVAL '88 days', NOW() - INTERVAL '95 days'), + ('inv_013', 'sbs_007', 'sub_007', 9.99, 'USD', 'refunded', NOW() - INTERVAL '60 days', NOW() - INTERVAL '58 days', NOW() - INTERVAL '65 days'), + ('inv_014', 'sbs_008', 'sub_008', 4.99, 'USD', 'paid', NOW() - INTERVAL '5 days', NOW() - INTERVAL '4 days', NOW() - INTERVAL '5 days'), + ('inv_015', 'sbs_008', 'sub_008', 4.99, 'USD', 'pending', NOW() + INTERVAL '25 days', NULL, NOW()), + ('inv_016', 'sbs_009', 'sub_009', 29.99, 'USD', 'paid', NOW() - INTERVAL '3 days', NOW() - INTERVAL '2 days', NOW() - INTERVAL '3 days'), + ('inv_017', 'sbs_009', 'sub_009', 29.99, 'USD', 'pending', NOW() + INTERVAL '20 days', NULL, NOW()), + ('inv_018', 'sbs_010', 'sub_010', 49.99, 'USD', 'paid', NOW() - INTERVAL '1 day', NOW() - INTERVAL '0 days', NOW() - INTERVAL '1 day'), + ('inv_019', 'sbs_010', 'sub_010', 49.99, 'USD', 'pending', NOW() + INTERVAL '28 days', NULL, NOW()), + ('inv_020', 'sbs_010', 'sub_010', 49.99, 'USD', 'pending', NOW() + INTERVAL '56 days', NULL, NOW()) +ON CONFLICT (id) DO NOTHING;