diff --git a/.gitignore b/.gitignore
index e4b7401..c908d28 100644
--- a/.gitignore
+++ b/.gitignore
@@ -70,4 +70,5 @@ apps/backend/dist/
zip/
-claude --resume 4d70ff1e-c37e-4e4d-ba08-121ee0b8450f
\ No newline at end of file
+claude --resume 4d70ff1e-c37e-4e4d-ba08-121ee0b8450f
+.ec2.env
diff --git a/META_RETURN_PRIME.md b/META_RETURN_PRIME.md
new file mode 100644
index 0000000..035f9a7
--- /dev/null
+++ b/META_RETURN_PRIME.md
@@ -0,0 +1,54 @@
+# Meta & Return Prime Integrations
+
+Two merchant-facing apps built on the Ratio App Ecosystem, each solving a problem created by running on a custom (non-native-Shopify) commerce stack: merchants lose out-of-the-box access to standard Shopify-app integrations, so we rebuilt the bridge ourselves.
+
+---
+
+## 1. Meta (Facebook Pixel + Conversions API + Catalog Sync)
+
+**Status:** built, local-tested (not yet deployed)
+
+### What we built
+- A self-serve admin app where a merchant connects their Facebook Pixel + Conversions API (CAPI) access token — no manual dev work per merchant.
+- **Client-side tracking**: a per-merchant Pixel script (`/meta/sdk/:merchantId.js`) that the storefront loads, config-driven from the DB (pixel ID, event settings) — no hardcoded merchant values.
+- **Server-side tracking (CAPI)**: events are forwarded directly to Meta's Graph API from our backend, not just the browser. Personal data (email, phone) is SHA-256-hashed before it ever leaves our servers.
+- **Reliability layer**: CAPI events are queued (AWS SQS) and dispatched in batches by a background worker, so a Meta API hiccup doesn't drop events or block checkout.
+- **Catalog Sync (Phase 2)**: product feed automatically kept in sync with Meta's Commerce Manager (create/update/delete webhooks + a pull-based feed endpoint), so merchants don't have to hand-manage their product catalog for ads.
+- **Security**: access tokens are encrypted at rest and never exposed to the browser or returned by the API — only a "connected" flag.
+- A stats view showing each merchant their own event delivery numbers.
+
+### Why it matters
+- **Better ad performance**: server-side CAPI events aren't blocked by ad-blockers or iOS tracking restrictions the way browser-only Pixel tracking is — merchants get more complete, more accurate conversion data, which directly improves Meta's ad-targeting/optimization and lowers cost-per-acquisition.
+- **Retargeting without manual work**: catalog sync means dynamic product ads and retargeting "stay fresh" automatically as merchants add/change/remove products.
+- **Zero engineering lift per merchant**: any merchant on the platform can connect their own Pixel/catalog in minutes, self-serve — this used to require a bespoke integration per store.
+- **Safe by default**: hashing + encryption means we can offer this without creating a PII liability.
+
+---
+
+## 2. Return Prime (Returns & Exchange Management)
+
+**Status:** built (backend module `rp` + `admin-rp` config app)
+
+### The problem
+Return Prime is a returns/exchange-automation product built to talk to **Shopify's Admin API**. Our merchants run on GoKwik's custom order/checkout backend ("OS") instead of native Shopify, so Return Prime can't talk to them directly — there's no orders/refunds/discounts API in the shape it expects.
+
+### What we built
+A full **Shopify-Admin-API-compatible adapter layer** that sits between Return Prime and our real backend (GoKwik OS Order Service + Ratio App Ecosystem), so Return Prime works exactly as it would on a native Shopify store:
+- **Orders, refunds, customers, products, discounts** — each proxied and reshaped from our backend's format into the exact JSON shape Return Prime/Shopify expects (and back).
+- **OAuth install flow** — merchants install Return Prime like any other app; we issue and store encrypted access/refresh tokens per merchant.
+- **Webhooks bridge** — order/product events from our backend are translated into the Shopify-style webhook payloads Return Prime listens for.
+- **Self-serve customer return portal** — a hosted portal (`/rp/customer/portal`) customers land on to request a return or exchange, no support-ticket needed.
+- **Exchange discount codes** — when a customer exchanges instead of refunds, we auto-generate a discount code via the Ratio API so they can complete the swap at checkout.
+- **Merchant admin app (`admin-rp`)** — a small config UI merchants use to confirm their store is registered/connected.
+
+### Why it matters
+- **Unlocks an off-the-shelf app for a non-standard stack**: without this adapter, Return Prime — and by extension automated, self-serve returns — simply isn't possible on our checkout infrastructure. This closes that gap without asking Return Prime to change anything on their end.
+- **Less manual support work**: customers can request/track returns and exchanges themselves through the portal instead of emailing support.
+- **Faster exchanges, less refund leakage**: auto-generated exchange discount codes nudge customers toward exchanges over refunds.
+- **Reusable pattern**: this "adapter layer" approach (transform + proxy to Shopify-shaped API) is now a template for onboarding *any* other Shopify-app-ecosystem tool onto our custom backend in the future.
+
+---
+
+## Common thread
+
+Both integrations exist because our merchants run on a custom commerce backend (GoKwik/Ratio) instead of vanilla Shopify, which normally cuts them off from the standard app ecosystem (Meta's own Shopify app, Return Prime's Shopify app, etc.). In both cases we rebuilt the missing bridge ourselves — one as a first-party tracking pipeline (Meta), the other as a protocol-compatibility shim (Return Prime) — so merchants get the same app-store experience as a standard Shopify merchant would.
diff --git a/apps/admin-rp/index.html b/apps/admin-rp/index.html
new file mode 100644
index 0000000..45156e0
--- /dev/null
+++ b/apps/admin-rp/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Return Prime — Ratio App
+
+
+
+
+
+
diff --git a/apps/admin-rp/package.json b/apps/admin-rp/package.json
new file mode 100644
index 0000000..54f1681
--- /dev/null
+++ b/apps/admin-rp/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@ratio-app/admin-rp",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:cov": "vitest run --coverage",
+ "lint": "biome check src"
+ },
+ "dependencies": {
+ "@primathonos/orion": "^0.4.2",
+ "@ratio-app/shared": "workspace:*",
+ "@tanstack/react-query": "^5.59.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "zustand": "^5.0.0"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "2.1.0",
+ "@testing-library/jest-dom": "^6.6.0",
+ "@testing-library/react": "^16.0.0",
+ "@types/node": "^22.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "@vitest/coverage-v8": "^2.1.0",
+ "happy-dom": "^15.0.0",
+ "typescript": "^5.6.0",
+ "vite": "^6.0.0",
+ "vitest": "^2.1.0"
+ }
+}
diff --git a/apps/admin-rp/src/App.tsx b/apps/admin-rp/src/App.tsx
new file mode 100644
index 0000000..0010d41
--- /dev/null
+++ b/apps/admin-rp/src/App.tsx
@@ -0,0 +1,272 @@
+import {
+ Card,
+ Form,
+ Input,
+ OrionProvider,
+ PrimaryButton,
+ Result,
+ Spin,
+ Typography,
+} from '@primathonos/orion';
+import { useEffect, useState } from 'react';
+import { useIframeAuth } from '@/hooks/useIframeAuth';
+import { ApiException, api } from '@/lib/api';
+import { installPostMessageListener, readSession } from '@/lib/session';
+import { useMerchantStore } from '@/stores/useMerchantStore';
+import './index.css';
+
+const RP_ADMIN_URL = (import.meta.env.VITE_RP_ADMIN_URL as string | undefined) ?? '';
+
+function centered(children: React.ReactNode) {
+ return (
+
+ {children}
+
+ );
+}
+
+function RegisterScreen() {
+ const [loading, setLoading] = useState(false);
+ const [domainLoading, setDomainLoading] = useState(true);
+ const [registered, setRegistered] = useState(false);
+ const [merchantDomain, setMerchantDomain] = useState(null);
+ const [error, setError] = useState(null);
+ const [form] = Form.useForm<{
+ store_domain: string;
+ admin_email: string;
+ admin_password: string;
+ confirm_password: string;
+ }>();
+
+ useEffect(() => {
+ api<{ domain: string; registered: boolean }>('GET', '/api/admin/merchants/me')
+ .then((me) => {
+ setMerchantDomain(me.domain);
+ if (me.registered) {
+ setRegistered(true);
+ } else {
+ form.setFieldsValue({
+ store_domain: me.domain,
+ admin_email: `admin@${me.domain}`,
+ });
+ }
+ })
+ .catch(() => {})
+ .finally(() => setDomainLoading(false));
+ }, [form]);
+
+ async function handleRegister(values: {
+ store_domain: string;
+ admin_email: string;
+ admin_password: string;
+ }) {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await api<{ domain: string }>('POST', '/api/admin/register', {
+ store_domain: values.store_domain,
+ admin_email: values.admin_email,
+ admin_password: values.admin_password,
+ });
+ setMerchantDomain(res.domain ?? values.store_domain);
+ setRegistered(true);
+ } catch (err) {
+ setError(
+ err instanceof ApiException ? err.message : 'Registration failed. Please try again.',
+ );
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ if (registered) {
+ const rpUrl = merchantDomain
+ ? `${RP_ADMIN_URL}/user/login?store=${merchantDomain}`
+ : RP_ADMIN_URL;
+ const adapterUrl = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '';
+ const sdkSrc = `${adapterUrl}/rp/sdk/rp-portal.js?store=${encodeURIComponent(merchantDomain ?? '')}&redirectTo=/apps/return_prime`;
+ const scriptSnippet = [
+ ``,
+ ``,
+ ``,
+ ``,
+ ].join('\n');
+
+ return centered(
+
+
window.open(rpUrl, '_blank')}>
+ Open Return Prime Dashboard
+
+ ) : null
+ }
+ />
+
+
Storefront SDK snippet
+
+ {scriptSnippet}
+
+
+ ,
+ );
+ }
+
+ if (domainLoading) return centered();
+
+ return centered(
+ ,
+ );
+}
+
+export function App() {
+ const { isAuthorized, parentOrigin } = useIframeAuth();
+ const token = useMerchantStore((s) => s.token);
+ const setToken = useMerchantStore((s) => s.setToken);
+ const [sessionChecked, setSessionChecked] = useState(false);
+
+ useEffect(() => {
+ setToken(readSession());
+ setSessionChecked(true);
+ return installPostMessageListener((id) => setToken(id));
+ }, [setToken]);
+
+ if (isAuthorized === null) return centered();
+
+ if (!isAuthorized) {
+ return centered(
+ ,
+ );
+ }
+
+ if (!sessionChecked) return centered();
+
+ if (!token) {
+ return centered(
+ ,
+ );
+ }
+
+ return ;
+}
+
+export function Root() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/admin-rp/src/hooks/useIframeAuth.ts b/apps/admin-rp/src/hooks/useIframeAuth.ts
new file mode 100644
index 0000000..d0c3530
--- /dev/null
+++ b/apps/admin-rp/src/hooks/useIframeAuth.ts
@@ -0,0 +1,61 @@
+import { useEffect, useState } from 'react';
+
+export interface IframeAuthState {
+ isAuthorized: boolean | null;
+ parentOrigin: string | null;
+}
+
+const ALLOWED_HOST_SUFFIXES = ['.gokwik.co', '.gokwik.in'];
+
+const REQUIRE_IFRAME =
+ ((import.meta.env.VITE_REQUIRE_IFRAME as string | undefined) ?? 'true').toLowerCase() !== 'false';
+
+function isLocalhost(host: string): boolean {
+ return host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0';
+}
+
+function isAllowedOrigin(origin: string): boolean {
+ try {
+ const url = new URL(origin);
+ if (url.protocol !== 'https:') return false;
+ return ALLOWED_HOST_SUFFIXES.some(
+ (suffix) => url.hostname === suffix.slice(1) || url.hostname.endsWith(suffix),
+ );
+ } catch {
+ return false;
+ }
+}
+
+function detectParentOrigin(): string | null {
+ const ancestors = (window.location as Location & { ancestorOrigins?: DOMStringList })
+ .ancestorOrigins;
+ if (ancestors && ancestors.length > 0) return ancestors[0] ?? null;
+ return document.referrer || null;
+}
+
+export function useIframeAuth(): IframeAuthState {
+ const [state, setState] = useState({ isAuthorized: null, parentOrigin: null });
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ if (!REQUIRE_IFRAME) {
+ setState({ isAuthorized: true, parentOrigin: null });
+ return;
+ }
+ if (isLocalhost(window.location.hostname)) {
+ setState({ isAuthorized: true, parentOrigin: null });
+ return;
+ }
+ if (window.self === window.top) {
+ setState({ isAuthorized: false, parentOrigin: null });
+ return;
+ }
+ const parent = detectParentOrigin();
+ setState({
+ isAuthorized: parent ? isAllowedOrigin(parent) : false,
+ parentOrigin: parent,
+ });
+ }, []);
+
+ return state;
+}
diff --git a/apps/admin-rp/src/index.css b/apps/admin-rp/src/index.css
new file mode 100644
index 0000000..9cc8987
--- /dev/null
+++ b/apps/admin-rp/src/index.css
@@ -0,0 +1,27 @@
+@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
+ background: #fafafa;
+}
+
+.container {
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 1.5rem 1rem;
+}
+
+@media (max-width: 600px) {
+ .container {
+ padding: 1rem 0.75rem;
+ }
+}
diff --git a/apps/admin-rp/src/lib/api.ts b/apps/admin-rp/src/lib/api.ts
new file mode 100644
index 0000000..763e0f9
--- /dev/null
+++ b/apps/admin-rp/src/lib/api.ts
@@ -0,0 +1,42 @@
+import { useMerchantStore } from '@/stores/useMerchantStore';
+
+export class ApiException extends Error {
+ constructor(
+ message: string,
+ public readonly status: number,
+ public readonly errorCode?: string,
+ ) {
+ super(message);
+ this.name = 'ApiException';
+ }
+}
+
+// In dev: Vite proxy routes /rp/* → :3100 (relative path works).
+// In production (marketplace ZIP): must use an absolute VITE_API_BASE_URL so
+// the CDN-served SPA can reach the backend.
+const RAW_BASE = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '';
+const BASE = RAW_BASE
+ ? RAW_BASE.endsWith('/')
+ ? `${RAW_BASE.slice(0, -1)}/rp`
+ : `${RAW_BASE}/rp`
+ : '/rp';
+
+export async function api(method: string, path: string, body?: unknown): Promise {
+ const headers: Record = { accept: 'application/json' };
+ if (body !== undefined) headers['content-type'] = 'application/json';
+ const token = useMerchantStore.getState().token;
+ if (token) headers.authorization = `Bearer ${token}`;
+ const init: RequestInit = { method, headers, credentials: 'include' };
+ if (body !== undefined) init.body = JSON.stringify(body);
+ const res = await fetch(`${BASE}${path}`, init);
+ const text = await res.text();
+ const json = text ? (JSON.parse(text) as Record) : {};
+ if (!res.ok) {
+ throw new ApiException(
+ (json.message as string) ?? res.statusText,
+ res.status,
+ json.error_code as string | undefined,
+ );
+ }
+ return (json.data ?? json) as T;
+}
diff --git a/apps/admin-rp/src/lib/session.ts b/apps/admin-rp/src/lib/session.ts
new file mode 100644
index 0000000..01521dd
--- /dev/null
+++ b/apps/admin-rp/src/lib/session.ts
@@ -0,0 +1,31 @@
+const STORAGE_KEY = 'rp-ratio:merchant-id';
+
+export function readSession(): string | null {
+ const url = new URL(window.location.href);
+ const fromUrl = url.searchParams.get('merchant-id');
+ if (fromUrl) {
+ window.localStorage.setItem(STORAGE_KEY, fromUrl);
+ url.searchParams.delete('merchant-id');
+ window.history.replaceState({}, '', url.toString());
+ return fromUrl;
+ }
+ return window.localStorage.getItem(STORAGE_KEY);
+}
+
+export function clearSession(): void {
+ window.localStorage.removeItem(STORAGE_KEY);
+}
+
+export function installPostMessageListener(onSession: (id: string) => void): () => void {
+ const allowed = (import.meta.env.VITE_RATIO_DASHBOARD_ORIGIN as string | undefined) ?? '';
+ const handler = (ev: MessageEvent): void => {
+ if (allowed && ev.origin !== allowed) return;
+ const data = ev.data as { type?: string; merchantId?: string } | null;
+ if (data?.type === 'ratio:session' && typeof data.merchantId === 'string') {
+ window.localStorage.setItem(STORAGE_KEY, data.merchantId);
+ onSession(data.merchantId);
+ }
+ };
+ window.addEventListener('message', handler);
+ return () => window.removeEventListener('message', handler);
+}
diff --git a/apps/admin-rp/src/main.tsx b/apps/admin-rp/src/main.tsx
new file mode 100644
index 0000000..5591530
--- /dev/null
+++ b/apps/admin-rp/src/main.tsx
@@ -0,0 +1,12 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { Root } from './App';
+
+const root = document.getElementById('root');
+if (!root) throw new Error('#root element not found');
+
+createRoot(root).render(
+
+
+ ,
+);
diff --git a/apps/admin-rp/src/stores/useMerchantStore.ts b/apps/admin-rp/src/stores/useMerchantStore.ts
new file mode 100644
index 0000000..8e43d9b
--- /dev/null
+++ b/apps/admin-rp/src/stores/useMerchantStore.ts
@@ -0,0 +1,13 @@
+import { create } from 'zustand';
+
+interface MerchantState {
+ token: string | null;
+ setToken: (token: string | null) => void;
+ clear: () => void;
+}
+
+export const useMerchantStore = create((set) => ({
+ token: null,
+ setToken: (token) => set({ token }),
+ clear: () => set({ token: null }),
+}));
diff --git a/apps/admin-rp/src/test-setup.ts b/apps/admin-rp/src/test-setup.ts
new file mode 100644
index 0000000..bb02c60
--- /dev/null
+++ b/apps/admin-rp/src/test-setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom/vitest';
diff --git a/apps/admin-rp/tsconfig.json b/apps/admin-rp/tsconfig.json
new file mode 100644
index 0000000..05eba2a
--- /dev/null
+++ b/apps/admin-rp/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "rootDir": "../..",
+ "outDir": "dist",
+ "noEmit": true,
+ "types": ["node", "vite/client"],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/apps/admin-rp/vite.config.ts b/apps/admin-rp/vite.config.ts
new file mode 100644
index 0000000..1514983
--- /dev/null
+++ b/apps/admin-rp/vite.config.ts
@@ -0,0 +1,57 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+
+const ENV_FILES: Array = [['../../.env', false]];
+if (process.env.NODE_ENV === 'production') {
+ ENV_FILES.push(['../../.env.production', true]);
+}
+for (const [relPath, override] of ENV_FILES) {
+ const filePath = resolve(__dirname, relPath);
+ try {
+ for (const line of readFileSync(filePath, 'utf8').split('\n')) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) continue;
+ const eq = trimmed.indexOf('=');
+ if (eq < 1) continue;
+ const key = trimmed.slice(0, eq).trim();
+ const value = trimmed.slice(eq + 1).trim();
+ if (!key.startsWith('VITE_')) continue;
+ if (override || !(key in process.env)) {
+ process.env[key] = value;
+ }
+ }
+ console.log(`[vite] loaded env from ${filePath}${override ? ' (override)' : ''}`);
+ } catch {
+ // file not present — fine for CI
+ }
+}
+
+export default defineConfig({
+ base: './',
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, 'src'),
+ },
+ },
+ server: {
+ port: 5000,
+ proxy: {
+ '/rp/api/admin': { target: 'http://localhost:3100', changeOrigin: true },
+ },
+ },
+ test: {
+ environment: 'happy-dom',
+ setupFiles: ['./src/test-setup.ts'],
+ include: ['src/**/*.test.{ts,tsx}'],
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'html', 'json-summary'],
+ reportsDirectory: './coverage',
+ include: ['src/**/*.{ts,tsx}'],
+ exclude: ['src/**/*.test.{ts,tsx}', 'src/test-setup.ts', 'src/main.tsx'],
+ },
+ },
+});
diff --git a/apps/backend/FLOW.md b/apps/backend/FLOW.md
new file mode 100644
index 0000000..1751a6d
--- /dev/null
+++ b/apps/backend/FLOW.md
@@ -0,0 +1,352 @@
+# RP Adapter — Full End-to-End Flow
+
+> **Format:** vertical box-and-arrow (top-to-bottom).
+> Actor labels sit above each box. Arrows carry the call / payload.
+> Real function names, env vars, and header names used throughout.
+
+---
+
+## Legend
+
+| Actor | Symbol |
+|-------|--------|
+| Merchant browser | `[ MERCHANT ]` |
+| Customer browser | `[ CUSTOMER ]` |
+| RP Adapter :3100 | `[ ADAPTER ]` |
+| RP Backend :4000 | `[ RP BE ]` |
+| OS Order Service | `[ OS-ORDER ]` |
+| OS Item Service | `[ OS-ITEM ]` |
+| MySQL (rp_app) | `[ MYSQL ]` |
+| MongoDB (return_prime_local) | `[ MONGO ]` |
+
+---
+
+## Phase 1 — Merchant installs app from Ratio App Store
+
+```
+[ MERCHANT ]
+┌──────────────────────────────────────────────────────────────────────┐
+│ Opens Ratio App Store → finds "Return Prime" → clicks Install │
+└────────────────────────────┬─────────────────────────────────────────┘
+ │ Ratio constructs OAuth URL, redirects
+ ▼
+[ ADAPTER ] GET /rp/auth/callback?code=
+┌──────────────────────────────────────────────────────────────────────┐
+│ RpAuthController.callback() │
+│ │
+│ POST RATIO_API_BASE_URL/api/v1/oauth/token │
+│ { grant_type: "authorization_code", code, │
+│ clientId: RATIO_RP_CLIENT_ID, │
+│ clientSecret: RATIO_RP_CLIENT_SECRET } │
+│ ← { access_token, refresh_token, expires_in, merchant_id } │
+│ │
+│ merchants.upsert({ merchantId, domain, accessTokenEnc, … }) │
+└────────────────────────────┬─────────────────────────────────────────┘
+ │
+ ▼
+[ MYSQL ] INSERT INTO rp_merchants (merchant_id, domain, …)
+
+ │ 302 → RATIO_RP_ADMIN_BASE_URL
+ ▼
+[ MERCHANT ] Admin SPA opens
+┌──────────────────────────────────────────────────────────────────────┐
+│ GET /rp/api/admin/merchants/me │
+│ Authorization: Bearer │
+│ ← { id, domain, registered: false } │
+│ │
+│ SPA shows Registration Form: │
+│ store_domain · admin_email · admin_password · admin_name │
+└────────────────────────────┬─────────────────────────────────────────┘
+ │ Merchant fills form → Submit
+ ▼
+[ ADAPTER ] POST /rp/api/admin/register
+┌──────────────────────────────────────────────────────────────────────┐
+│ RpAdminController.register() │
+│ body: { store_domain, admin_email, admin_password, admin_name } │
+│ │
+│ merchants.updateDomain(merchantId, storeDomain) → MySQL UPDATE │
+│ │
+│ POST RP_BASE_URL/shopify-webhook/v1/os-install ← (1) │
+│ X-OS-Internal-Token: RP_INTERNAL_API_TOKEN │
+│ X-OS-Store: sandbox-bblunt-v2.dev.gokwik.io │
+│ { merchant_id, gokwik_merchant_id, access_token, │
+│ admin_email, admin_password, platform: "os" } │
+└────────────────────────────┬─────────────────────────────────────────┘
+ │
+ ▼
+[ RP BE ] POST /shopify-webhook/v1/os-install
+┌──────────────────────────────────────────────────────────────────────┐
+│ Creates store record in RP's own MongoDB stores collection │
+│ ← 200 { status: true, messageCode: "OS_INSTALL_S1", │
+│ message: "Store registered" } │
+└────────────────────────────┬─────────────────────────────────────────┘
+ │
+ ▼
+[ MERCHANT ] Admin SPA shows success
+┌──────────────────────────────────────────────────────────────────────┐
+│ Script snippet to paste into BBLunt storefront theme: │
+│ │
+│ (served by adapter: GET /rp/sdk/rp-portal.js) │
+│ │
+│ Button → "Open RP Dashboard" → http://localhost:3000 │
+│ OS platform mode: no billing screen, free tier by default │
+│ Merchant configures: return policy · reasons · locations │
+└──────────────────────────────────────────────────────────────────────┘
+```
+
+> **(1)** `os-install` is a standard RP webhook handler — same shape as the
+> Shopify `app/installed` webhook. It's the hook Shopify itself would call;
+> for the OS platform we post it explicitly from the adapter on registration.
+
+---
+
+## Phase 2 — Order placed on BBLunt storefront → synced to RP
+
+```
+[ CUSTOMER ]
+┌──────────────────────────────────────────────────────────────────────┐
+│ Completes checkout on sandbox-bblunt-v2.dev.gokwik.io │
+│ OS creates order: id = "ordr_17834274629567154" │
+│ line_items: [{ sku: "8904417307375", price: 48900 }] ← paise │
+└────────────────────────────┬─────────────────────────────────────────┘
+ │ OS Order Service fires webhook
+ ▼
+[ OS-ORDER ] POST https://…devtunnels.ms/rp/webhooks/orders
+ x-merchant-id:
+ x-webhook-topic: orders/create
+ body: { event_type: "orders/create",
+ merchant_id: ,
+ order: { id: "ordr_…", line_items: […] } }
+ │
+ ▼
+[ ADAPTER ] POST /rp/webhooks/orders
+┌──────────────────────────────────────────────────────────────────────┐
+│ RpWebhooksController.orderEvent() │
+│ Guard: RpWebhookSignatureGuard │
+│ no x-ratio-hmac-sha256 header present │
+│ NODE_ENV=development → guard passes (logs warning, continues) │
+│ │
+│ merchantId ← x-merchant-id header │
+│ ← body.merchant_id (fallback) │
+│ orderPayload ← body.order ?? body.data ?? body │
+│ │
+│ RpWebhooksService.handleOrderEvent(merchantId, payload, topic) │
+│ merchants.findByMerchantId(merchantId) │
+└────────────────┬───────────────────────────────────────┬─────────────┘
+ │ merchant found │ not found
+ ▼ ▼
+┌────────────────────────────────┐ ┌────────────────────────┐
+│ orderSync.upsertOrder( │ │ logger.warn → drop │
+│ payload, merchant.domain ) │ │ return 200 { ok:true } │
+└───────────────┬────────────────┘ └────────────────────────┘
+ │
+ ▼
+[ ADAPTER ] RpOrderSyncService.upsertOrder(rawOrder, domain)
+┌──────────────────────────────────────────────────────────────────────┐
+│ normalizeOrder({ ...rawOrder, store: domain }) │
+│ numericIdFromString("ordr_17834274629567154") │
+│ strip prefix → BigInt hex → 1114642164347947 │
+│ paiseToRupee(48900) → 489.00 ← all price fields ÷ 100 │
+│ build price_set, discount_allocations, tax_lines (Shopify shape) │
+└────────────────────────────┬─────────────────────────────────────────┘
+ │
+ ▼
+[ MONGO ] shopifyorders.updateOne(
+ { id: 1114642164347947, store: "sandbox-bblunt-v2…" },
+ { $set: { id, name, store, financial_status,
+ line_items: [{ price: "489.00", … }],
+ updated_at: now() } },
+ { upsert: true }
+ )
+ │
+ ▼ 200 { ok: true } back to OS-ORDER
+
+──────────────────────────────────────────────────────────────────────
+ Verify received:
+ mongosh …/return_prime_local --eval 'db.shopifyorders.findOne(
+ {}, {id:1,name:1,store:1,updated_at:1,_id:0})'
+──────────────────────────────────────────────────────────────────────
+```
+
+---
+
+## Phase 3 — Customer opens /apps/return_prime and submits return
+
+```
+[ CUSTOMER ]
+┌──────────────────────────────────────────────────────────────────────┐
+│ Visits /apps/return_prime on BBLunt storefront │
+│