Browse Source

fix(billing): switch ShopRenter billing API to Bearer token auth

ShopRenter deployed payment API support for Bearer token authentication.
Replace Basic Auth (SHOPRENTER_APP_PAYMENT_USERNAME/SHOPRENTER_APP_API_TOKEN)
with the same OAuth Bearer token used for product/order/customer API calls
via getValidAccessToken().
Fszontagh 3 weeks ago
parent
commit
0865b543cf

+ 3 - 0
.gitmodules

@@ -0,0 +1,3 @@
+[submodule "docs/sr-api-docs"]
+	path = docs/sr-api-docs
+	url = https://github.com/Shoprenter/sr-api-docs.git

+ 1 - 0
docs/sr-api-docs

@@ -0,0 +1 @@
+Subproject commit 9070903711fe7454890bfc1c8db99c6e54d85301

+ 1286 - 0
supabase/functions/_shared/shoprenter-billing-client.ts

@@ -0,0 +1,1286 @@
+/**
+ * ShopRenter Billing API Client
+ *
+ * This client supports TWO separate ShopRenter billing APIs:
+ *
+ * 1. PARTNER API (billing.shoprenter.hu/api)
+ *    - Used for: Managing plans, payers, and partner-level operations
+ *    - Auth: Basic Auth (SHOPRENTER_BILLING_USERNAME:SHOPRENTER_BILLING_TOKEN)
+ *    - Limitations: No one-time charges
+ *
+ * 2. SHOP API (<shopname>.api.myshoprenter.hu/billing)
+ *    - Used for: Creating charges on specific shops
+ *    - Auth: Bearer token (same OAuth token used for product/order API)
+ *    - Supports: One-time charges AND recurring charges
+ *    - This is the main API for billing shop owners
+ *
+ * For OAuth apps, the Shop API uses the same Bearer token as the regular API.
+ */
+
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
+import { getValidAccessToken } from './shoprenter-client.ts'
+
+// =============================================================================
+// TYPES
+// =============================================================================
+
+export interface ShopRenterPlan {
+  id: number
+  name: string
+  netPrice: number
+  billingCycleLength: number  // in days (30 = monthly)
+  billingCycleCount: number | null  // null = unlimited
+  createdAt: string
+  updatedAt: string
+}
+
+export interface ShopRenterPlanCreateRequest {
+  name: string
+  netPrice: number  // Net price in HUF
+  billingCycleLength: number  // in days (30 = monthly)
+  billingCycleCount?: number | null  // null = unlimited
+}
+
+export interface ShopRenterPayer {
+  id: number
+  name: string
+  email: string
+  domain: string
+  zipCode?: string
+  city?: string
+  address?: string
+  country?: string
+  taxNumber?: string
+  euTaxNumber?: string
+}
+
+export interface ShopRenterPayerCreateRequest {
+  name: string
+  email: string
+  zipCode: string
+  city: string
+  address: string
+  country: string  // e.g., "HU"
+  domain: string  // e.g., "shopname.myshoprenter.hu"
+  taxNumber?: string
+  euTaxNumber?: string
+}
+
+export interface ShopRenterSubscription {
+  id: number
+  planId: number
+  payerId: number
+  name: string
+  status: ShopRenterSubscriptionStatus
+  netPrice: number
+  price?: {
+    grossAmount: number
+    vatAmount: number
+    netPrice: number
+    roundedGrossAmount: number
+  }
+  paymentUrl: string  // Often empty for OAuth apps
+  successUrl: string
+  failedUrl: string
+  notificationUrl?: string
+  test: boolean
+  trialDays?: number
+  billingCycleLength?: number
+  billingCycleCount?: number | null
+  expirationDate?: string | null
+  createdAt: string
+  updatedAt?: string
+  deletedAt?: string | null
+}
+
+export interface ShopRenterSubscriptionCreateRequest {
+  payerId: number
+  planId: number
+  test: boolean
+  successUrl: string
+  failedUrl: string
+  notificationUrl?: string
+  trialDays?: number
+}
+
+export type ShopRenterSubscriptionStatus =
+  | 'pending'     // Awaiting payment confirmation
+  | 'active'      // Active and billing
+  | 'frozen'      // Payment failed, retrying for 15 days
+  | 'cancelled'   // Subscription cancelled
+  | 'declined'    // Initial payment declined
+
+export interface ShopRenterWebhookPayload {
+  id: number
+  status: string
+  time: number  // Unix timestamp (UTC)
+  reason?: string | null
+}
+
+// Legacy types for backwards compatibility
+export interface ShopRenterRecurringChargeRequest {
+  planId: number
+  notificationUrl: string
+  failedUrl: string
+  successUrl: string
+  trialDays?: number
+  test?: boolean
+}
+
+export interface ShopRenterOneTimeChargeRequest {
+  name: string
+  netPrice: number
+  notificationUrl: string
+  failedUrl: string
+  successUrl: string
+  test?: boolean
+}
+
+export interface ShopRenterChargeResponse {
+  id: number
+  status: string
+  netPrice: number
+  grossPrice?: number
+  vatPrice?: number
+  confirmationUrl: string
+  createdAt: string
+  updatedAt?: string
+  planId?: number
+  billingCycleLength?: number
+  billingCycleCount?: number | null
+  trialDays?: number
+  name?: string
+}
+
+// =============================================================================
+// SHOP API TYPES (for <shopname>.api.myshoprenter.hu/billing)
+// =============================================================================
+
+export interface ShopApiOneTimeChargeRequest {
+  name: string
+  netPrice: number  // Net price in HUF
+  notificationUrl?: string
+  failedUrl: string
+  successUrl: string
+  test?: boolean
+}
+
+export interface ShopApiOneTimeChargeResponse {
+  id: number
+  name: string
+  status: 'pending' | 'active' | 'declined' | 'cancelled'
+  price: {
+    grossAmount: number
+    vatAmount: number
+    netPrice: number
+    roundedGrossAmount: number
+  }
+  netPrice: number
+  paymentUrl: string  // Usually empty, use confirmationUrl instead
+  notificationUrl: string | null
+  successUrl: string
+  failedUrl: string
+  updatedAt: string
+  createdAt: string
+  deletedAt: string | null
+  test: boolean
+  confirmationUrl: string
+}
+
+export interface ShopApiRecurringChargeRequest {
+  planId: number
+  notificationUrl?: string
+  failedUrl: string
+  successUrl: string
+  test?: boolean
+  trialDays?: number
+}
+
+export interface ShopApiRecurringChargeResponse {
+  planId: number
+  billingCycleLength: number
+  billingCycleCount: number | null
+  expirationDate: string | null
+  trialDays: number
+  id: number
+  name: string
+  status: 'pending' | 'active' | 'frozen' | 'cancelled' | 'declined'
+  price: {
+    grossAmount: number
+    vatAmount: number
+    netPrice: number
+    roundedGrossAmount: number
+  }
+  netPrice: number
+  paymentUrl: string
+  notificationUrl: string | null
+  successUrl: string
+  failedUrl: string
+  updatedAt: string
+  createdAt: string
+  deletedAt: string | null
+  test: boolean
+  confirmationUrl: string
+}
+
+// =============================================================================
+// PARTNER API CONFIGURATION
+// =============================================================================
+
+const BILLING_API_HOST = 'billing.shoprenter.hu'
+const BILLING_API_BASE = '/api'
+
+interface PartnerCredentials {
+  username: string
+  token: string
+}
+
+function getPartnerCredentials(): PartnerCredentials {
+  const username = Deno.env.get('SHOPRENTER_BILLING_USERNAME')
+  const token = Deno.env.get('SHOPRENTER_BILLING_TOKEN')
+
+  if (!username || !token) {
+    throw new Error('Missing SHOPRENTER_BILLING_USERNAME or SHOPRENTER_BILLING_TOKEN environment variables')
+  }
+
+  return { username, token }
+}
+
+// =============================================================================
+// SHOP API CONFIGURATION (for <shopname>.api.myshoprenter.hu/billing)
+// Now uses Bearer token authentication (same as product/order API)
+// =============================================================================
+
+// =============================================================================
+// HTTP/1.0 Request Helper
+// ShopRenter API sometimes has issues with HTTP/2, using HTTP/1.0 for reliability
+// =============================================================================
+
+async function makePartnerApiRequest(
+  path: string,
+  method: string,
+  body?: Record<string, unknown>
+): Promise<{ status: number; statusText: string; body: string }> {
+  const { username, token } = getPartnerCredentials()
+
+  // Basic Auth header
+  const basicAuth = btoa(`${username}:${token}`)
+
+  const headers: Record<string, string> = {
+    'Authorization': `Basic ${basicAuth}`,
+    'X-Auth-Token': token,
+    'Accept': 'application/json',
+  }
+
+  let bodyJson: string | undefined
+  if (body) {
+    bodyJson = JSON.stringify(body)
+    headers['Content-Type'] = 'application/json'
+    headers['Content-Length'] = new TextEncoder().encode(bodyJson).length.toString()
+  }
+
+  const fullPath = `${BILLING_API_BASE}${path}`
+  console.log(`[ShopRenter Billing] ${method} https://${BILLING_API_HOST}${fullPath}`)
+  if (bodyJson) {
+    console.log('[ShopRenter Billing] Request body:', bodyJson)
+  }
+
+  // Use HTTP/1.0 for reliability with ShopRenter's servers
+  const conn = await Deno.connectTls({
+    hostname: BILLING_API_HOST,
+    port: 443,
+  })
+
+  try {
+    const requestLines: string[] = [
+      `${method} ${fullPath} HTTP/1.0`,
+      `Host: ${BILLING_API_HOST}`,
+    ]
+
+    for (const [key, value] of Object.entries(headers)) {
+      requestLines.push(`${key}: ${value}`)
+    }
+
+    requestLines.push('Connection: close')
+    requestLines.push('')
+    requestLines.push('')
+
+    let request = requestLines.join('\r\n')
+    if (bodyJson) {
+      request = request + bodyJson
+    }
+
+    const encoder = new TextEncoder()
+    await conn.write(encoder.encode(request))
+
+    // Read response
+    const chunks: Uint8Array[] = []
+    const buffer = new Uint8Array(64 * 1024)
+    let totalBytes = 0
+
+    while (true) {
+      try {
+        const n = await conn.read(buffer)
+        if (n === null) break
+
+        const chunk = new Uint8Array(n)
+        chunk.set(buffer.subarray(0, n))
+        chunks.push(chunk)
+        totalBytes += n
+      } catch (e) {
+        if (e instanceof Deno.errors.UnexpectedEof ||
+            (e instanceof Error && e.name === 'UnexpectedEof')) {
+          break
+        }
+        throw e
+      }
+    }
+
+    // Concatenate chunks
+    const responseData = new Uint8Array(totalBytes)
+    let offset = 0
+    for (const chunk of chunks) {
+      responseData.set(chunk, offset)
+      offset += chunk.length
+    }
+
+    const decoder = new TextDecoder()
+
+    // Find header/body separator
+    let headerEndIndex = -1
+    let headerSeparatorLength = 0
+
+    for (let i = 0; i < responseData.length - 3; i++) {
+      if (responseData[i] === 13 && responseData[i + 1] === 10 &&
+          responseData[i + 2] === 13 && responseData[i + 3] === 10) {
+        headerEndIndex = i
+        headerSeparatorLength = 4
+        break
+      }
+    }
+
+    if (headerEndIndex === -1) {
+      for (let i = 0; i < responseData.length - 1; i++) {
+        if (responseData[i] === 10 && responseData[i + 1] === 10) {
+          headerEndIndex = i
+          headerSeparatorLength = 2
+          break
+        }
+      }
+    }
+
+    if (headerEndIndex === -1) {
+      throw new Error('Invalid HTTP response: no header/body separator found')
+    }
+
+    const headerBytes = responseData.subarray(0, headerEndIndex)
+    const headerSection = decoder.decode(headerBytes)
+    const bodyBytes = responseData.subarray(headerEndIndex + headerSeparatorLength)
+
+    // Parse status line
+    const lines = headerSection.split(/\r?\n/)
+    const statusLine = lines[0]
+    const statusMatch = statusLine.match(/HTTP\/1\.[01]\s+(\d+)\s+(.*)/)
+
+    if (!statusMatch) {
+      throw new Error(`Invalid HTTP status line: ${statusLine}`)
+    }
+
+    const status = parseInt(statusMatch[1])
+    const statusText = statusMatch[2]
+
+    // Parse headers for content-encoding
+    const responseHeaders: Record<string, string> = {}
+    for (let i = 1; i < lines.length; i++) {
+      const colonIndex = lines[i].indexOf(':')
+      if (colonIndex > 0) {
+        const key = lines[i].substring(0, colonIndex).trim().toLowerCase()
+        const value = lines[i].substring(colonIndex + 1).trim()
+        responseHeaders[key] = value
+      }
+    }
+
+    // Handle gzip
+    let bodyText: string
+    const contentEncoding = responseHeaders['content-encoding']
+
+    if (contentEncoding === 'gzip') {
+      try {
+        const decompressed = new DecompressionStream('gzip')
+        const writer = decompressed.writable.getWriter()
+        await writer.write(bodyBytes)
+        await writer.close()
+
+        const reader = decompressed.readable.getReader()
+        const decompressedChunks: Uint8Array[] = []
+        while (true) {
+          const { done, value } = await reader.read()
+          if (done) break
+          decompressedChunks.push(value)
+        }
+
+        const decompressedData = new Uint8Array(
+          decompressedChunks.reduce((acc, chunk) => acc + chunk.length, 0)
+        )
+        let pos = 0
+        for (const chunk of decompressedChunks) {
+          decompressedData.set(chunk, pos)
+          pos += chunk.length
+        }
+
+        bodyText = decoder.decode(decompressedData)
+      } catch {
+        bodyText = decoder.decode(bodyBytes)
+      }
+    } else {
+      bodyText = decoder.decode(bodyBytes)
+    }
+
+    console.log('[ShopRenter Billing] Response:', status, statusText)
+    if (bodyText) {
+      console.log('[ShopRenter Billing] Response body:', bodyText.substring(0, 500))
+    }
+
+    return { status, statusText, body: bodyText }
+  } finally {
+    try {
+      conn.close()
+    } catch {
+      // Connection already closed
+    }
+  }
+}
+
+// =============================================================================
+// SHOP API REQUEST HELPER
+// For <shopname>.api.myshoprenter.hu/billing endpoints
+// =============================================================================
+
+async function makeShopApiRequest(
+  shopname: string,
+  storeId: string,
+  path: string,
+  method: string,
+  body?: Record<string, unknown>
+): Promise<{ status: number; statusText: string; body: string }> {
+  // Use Bearer token authentication (same as product/order API)
+  const accessToken = await getValidAccessToken(storeId)
+
+  const headers: Record<string, string> = {
+    'Authorization': `Bearer ${accessToken}`,
+    'Accept': 'application/json',
+  }
+
+  let bodyJson: string | undefined
+  if (body) {
+    bodyJson = JSON.stringify(body)
+    headers['Content-Type'] = 'application/json'
+    headers['Content-Length'] = new TextEncoder().encode(bodyJson).length.toString()
+  }
+
+  const hostname = `${shopname}.api.myshoprenter.hu`
+  const fullPath = `/billing${path}`
+  console.log(`[ShopRenter Shop API] ${method} https://${hostname}${fullPath}`)
+  if (bodyJson) {
+    console.log('[ShopRenter Shop API] Request body:', bodyJson)
+  }
+
+  // Use HTTP/1.0 for reliability with ShopRenter's servers
+  const conn = await Deno.connectTls({
+    hostname,
+    port: 443,
+  })
+
+  try {
+    const requestLines: string[] = [
+      `${method} ${fullPath} HTTP/1.0`,
+      `Host: ${hostname}`,
+    ]
+
+    for (const [key, value] of Object.entries(headers)) {
+      requestLines.push(`${key}: ${value}`)
+    }
+
+    requestLines.push('Connection: close')
+    requestLines.push('')
+    requestLines.push('')
+
+    let request = requestLines.join('\r\n')
+    if (bodyJson) {
+      request = request + bodyJson
+    }
+
+    const encoder = new TextEncoder()
+    await conn.write(encoder.encode(request))
+
+    // Read response
+    const chunks: Uint8Array[] = []
+    const buffer = new Uint8Array(64 * 1024)
+    let totalBytes = 0
+
+    while (true) {
+      try {
+        const n = await conn.read(buffer)
+        if (n === null) break
+
+        const chunk = new Uint8Array(n)
+        chunk.set(buffer.subarray(0, n))
+        chunks.push(chunk)
+        totalBytes += n
+      } catch (e) {
+        if (e instanceof Deno.errors.UnexpectedEof ||
+            (e instanceof Error && e.name === 'UnexpectedEof')) {
+          break
+        }
+        throw e
+      }
+    }
+
+    // Concatenate chunks
+    const responseData = new Uint8Array(totalBytes)
+    let offset = 0
+    for (const chunk of chunks) {
+      responseData.set(chunk, offset)
+      offset += chunk.length
+    }
+
+    const decoder = new TextDecoder()
+
+    // Find header/body separator
+    let headerEndIndex = -1
+    let headerSeparatorLength = 0
+
+    for (let i = 0; i < responseData.length - 3; i++) {
+      if (responseData[i] === 13 && responseData[i + 1] === 10 &&
+          responseData[i + 2] === 13 && responseData[i + 3] === 10) {
+        headerEndIndex = i
+        headerSeparatorLength = 4
+        break
+      }
+    }
+
+    if (headerEndIndex === -1) {
+      for (let i = 0; i < responseData.length - 1; i++) {
+        if (responseData[i] === 10 && responseData[i + 1] === 10) {
+          headerEndIndex = i
+          headerSeparatorLength = 2
+          break
+        }
+      }
+    }
+
+    if (headerEndIndex === -1) {
+      throw new Error('Invalid HTTP response: no header/body separator found')
+    }
+
+    const headerBytes = responseData.subarray(0, headerEndIndex)
+    const headerSection = decoder.decode(headerBytes)
+    const bodyBytes = responseData.subarray(headerEndIndex + headerSeparatorLength)
+
+    // Parse status line
+    const lines = headerSection.split(/\r?\n/)
+    const statusLine = lines[0]
+    const statusMatch = statusLine.match(/HTTP\/1\.[01]\s+(\d+)\s+(.*)/)
+
+    if (!statusMatch) {
+      throw new Error(`Invalid HTTP status line: ${statusLine}`)
+    }
+
+    const status = parseInt(statusMatch[1])
+    const statusText = statusMatch[2]
+
+    // Parse headers for content-encoding
+    const responseHeaders: Record<string, string> = {}
+    for (let i = 1; i < lines.length; i++) {
+      const colonIndex = lines[i].indexOf(':')
+      if (colonIndex > 0) {
+        const key = lines[i].substring(0, colonIndex).trim().toLowerCase()
+        const value = lines[i].substring(colonIndex + 1).trim()
+        responseHeaders[key] = value
+      }
+    }
+
+    // Handle gzip
+    let bodyText: string
+    const contentEncoding = responseHeaders['content-encoding']
+
+    if (contentEncoding === 'gzip') {
+      try {
+        const decompressed = new DecompressionStream('gzip')
+        const writer = decompressed.writable.getWriter()
+        await writer.write(bodyBytes)
+        await writer.close()
+
+        const reader = decompressed.readable.getReader()
+        const decompressedChunks: Uint8Array[] = []
+        while (true) {
+          const { done, value } = await reader.read()
+          if (done) break
+          decompressedChunks.push(value)
+        }
+
+        const decompressedData = new Uint8Array(
+          decompressedChunks.reduce((acc, chunk) => acc + chunk.length, 0)
+        )
+        let pos = 0
+        for (const chunk of decompressedChunks) {
+          decompressedData.set(chunk, pos)
+          pos += chunk.length
+        }
+
+        bodyText = decoder.decode(decompressedData)
+      } catch {
+        bodyText = decoder.decode(bodyBytes)
+      }
+    } else {
+      bodyText = decoder.decode(bodyBytes)
+    }
+
+    console.log('[ShopRenter Shop API] Response:', status, statusText)
+    if (bodyText) {
+      console.log('[ShopRenter Shop API] Response body:', bodyText.substring(0, 500))
+    }
+
+    return { status, statusText, body: bodyText }
+  } finally {
+    try {
+      conn.close()
+    } catch {
+      // Connection already closed
+    }
+  }
+}
+
+// =============================================================================
+// SHOP API - ONE-TIME CHARGES
+// Use this for top-ups and single payments
+// =============================================================================
+
+/**
+ * Create a one-time charge via the Shop API
+ *
+ * This is the primary method for billing top-ups (minute purchases).
+ * Uses Basic Auth with payment credentials (SHOPRENTER_PAYMENT_USERNAME/TOKEN).
+ *
+ * @param shopname - The shop name (e.g., "myshop" from "myshop.shoprenter.hu")
+ * @param request - The charge request details
+ * @returns The created charge with confirmation URL
+ */
+export async function createShopApiOneTimeCharge(
+  shopname: string,
+  storeId: string,
+  request: ShopApiOneTimeChargeRequest
+): Promise<ShopApiOneTimeChargeResponse> {
+  const response = await makeShopApiRequest(shopname, storeId, '/oneTimeCharges', 'POST', {
+    name: request.name,
+    netPrice: request.netPrice,
+    notificationUrl: request.notificationUrl,
+    failedUrl: request.failedUrl,
+    successUrl: request.successUrl,
+    test: request.test ?? false,
+  })
+
+  if (response.status >= 400) {
+    throw new Error(`Failed to create one-time charge: ${response.status} ${response.statusText} - ${response.body}`)
+  }
+
+  return JSON.parse(response.body)
+}
+
+/**
+ * Get a one-time charge by ID via the Shop API
+ */
+export async function getShopApiOneTimeCharge(
+  shopname: string,
+  storeId: string,
+  chargeId: number
+): Promise<ShopApiOneTimeChargeResponse> {
+  const response = await makeShopApiRequest(shopname, storeId, `/oneTimeCharges/${chargeId}`, 'GET')
+
+  if (response.status >= 400) {
+    throw new Error(`Failed to get one-time charge: ${response.status} ${response.statusText}`)
+  }
+
+  return JSON.parse(response.body)
+}
+
+// =============================================================================
+// SHOP API - RECURRING CHARGES
+// Use this for subscriptions
+// =============================================================================
+
+/**
+ * Create a recurring charge via the Shop API
+ *
+ * This is an alternative to the Partner API for subscriptions.
+ * Uses Basic Auth with payment credentials.
+ *
+ * @param shopname - The shop name (e.g., "myshop" from "myshop.shoprenter.hu")
+ * @param request - The charge request details
+ * @returns The created recurring charge with confirmation URL
+ */
+export async function createShopApiRecurringCharge(
+  shopname: string,
+  storeId: string,
+  request: ShopApiRecurringChargeRequest
+): Promise<ShopApiRecurringChargeResponse> {
+  const response = await makeShopApiRequest(shopname, storeId, '/recurringCharges', 'POST', {
+    planId: request.planId,
+    notificationUrl: request.notificationUrl,
+    failedUrl: request.failedUrl,
+    successUrl: request.successUrl,
+    test: request.test ?? false,
+    trialDays: request.trialDays ?? 0,
+  })
+
+  if (response.status >= 400) {
+    throw new Error(`Failed to create recurring charge: ${response.status} ${response.statusText} - ${response.body}`)
+  }
+
+  return JSON.parse(response.body)
+}
+
+/**
+ * Get a recurring charge by ID via the Shop API
+ */
+export async function getShopApiRecurringCharge(
+  shopname: string,
+  storeId: string,
+  chargeId: number
+): Promise<ShopApiRecurringChargeResponse> {
+  const response = await makeShopApiRequest(shopname, storeId, `/recurringCharges/${chargeId}`, 'GET')
+
+  if (response.status >= 400) {
+    throw new Error(`Failed to get recurring charge: ${response.status} ${response.statusText}`)
+  }
+
+  return JSON.parse(response.body)
+}
+
+/**
+ * Cancel a recurring charge via the Shop API
+ */
+export async function cancelShopApiRecurringCharge(
+  shopname: string,
+  storeId: string,
+  chargeId: number
+): Promise<void> {
+  const response = await makeShopApiRequest(shopname, storeId, `/recurringCharges/${chargeId}`, 'DELETE')
+
+  if (response.status >= 400) {
+    throw new Error(`Failed to cancel recurring charge: ${response.status} ${response.statusText}`)
+  }
+}
+
+// =============================================================================
+// PLANS API (Partner API)
+// =============================================================================
+
+/**
+ * Create a new payment plan
+ */
+export async function createPlan(request: ShopRenterPlanCreateRequest): Promise<ShopRenterPlan> {
+  const response = await makePartnerApiRequest('/plans', 'POST', {
+    name: request.name,
+    netPrice: request.netPrice,
+    billingCycleLength: request.billingCycleLength,
+    billingCycleCount: request.billingCycleCount ?? null,
+  })
+
+  if (response.status >= 400) {
+    throw new Error(`Failed to create plan: ${response.status} ${response.statusText} - ${response.body}`)
+  }
+
+  return JSON.parse(response.body)
+}
+
+/**
+ * List all payment plans
+ */
+export async function listPlans(): Promise<ShopRenterPlan[]> {
+  const response = await makePartnerApiRequest('/plans', 'GET')
+
+  if (response.status >= 400) {
+    throw new Error(`Failed to list plans: ${response.status} ${response.statusText}`)
+  }
+
+  return JSON.parse(response.body)
+}
+
+/**
+ * Get a plan by ID
+ */
+export async function getPlan(planId: number): Promise<ShopRenterPlan> {
+  const response = await makePartnerApiRequest(`/plans/${planId}`, 'GET')
+
+  if (response.status >= 400) {
+    throw new Error(`Failed to get plan: ${response.status} ${response.statusText}`)
+  }
+
+  return JSON.parse(response.body)
+}
+
+// =============================================================================
+// PAYERS API
+// =============================================================================
+
+/**
+ * Create a new payer (shop owner who will be billed)
+ * Must be created before creating a subscription
+ *
+ * Note: If a payer with the same domain already exists, ShopRenter returns
+ * error "domain: This value is already used." In this case, the payer ID
+ * must be retrieved from the store's alt_data (shoprenter_payer_id).
+ */
+export async function createPayer(request: ShopRenterPayerCreateRequest): Promise<ShopRenterPayer> {
+  const response = await makePartnerApiRequest('/payers', 'POST', {
+    name: request.name,
+    email: request.email,
+    zipCode: request.zipCode,
+    city: request.city,
+    address: request.address,
+    country: request.country,
+    domain: request.domain,
+    taxNumber: request.taxNumber,
+    euTaxNumber: request.euTaxNumber,
+  })
+
+  if (response.status >= 400) {
+    // Check for "domain already used" error
+    if (response.body.includes('This value is already used') && response.body.includes('domain')) {
+      throw new Error(
+        `A billing payer already exists for this shop domain (${request.domain}). ` +
+        `Please contact support to retrieve your existing payer ID, or check if this store ` +
+        `was previously connected. Error: domain already registered`
+      )
+    }
+    throw new Error(`Failed to create payer: ${response.status} ${response.statusText} - ${response.body}`)
+  }
+
+  return JSON.parse(response.body)
+}
+
+/**
+ * List all payers
+ */
+export async function listPayers(): Promise<ShopRenterPayer[]> {
+  const response = await makePartnerApiRequest('/payers', 'GET')
+
+  if (response.status >= 400) {
+    throw new Error(`Failed to list payers: ${response.status} ${response.statusText}`)
+  }
+
+  return JSON.parse(response.body)
+}
+
+/**
+ * Get a payer by ID
+ */
+export async function getPayer(payerId: number): Promise<ShopRenterPayer> {
+  const response = await makePartnerApiRequest(`/payers/${payerId}`, 'GET')
+
+  if (response.status >= 400) {
+    throw new Error(`Failed to get payer: ${response.status} ${response.statusText}`)
+  }
+
+  return JSON.parse(response.body)
+}
+
+// =============================================================================
+// SUBSCRIPTIONS API
+// =============================================================================
+
+/**
+ * Create a new subscription
+ * Requires: payer must be created first, plan must exist
+ */
+export async function createSubscription(
+  request: ShopRenterSubscriptionCreateRequest
+): Promise<ShopRenterSubscription> {
+  const response = await makePartnerApiRequest('/subscriptions', 'POST', {
+    payerId: request.payerId,
+    planId: request.planId,
+    test: request.test,
+    successUrl: request.successUrl,
+    failedUrl: request.failedUrl,
+    notificationUrl: request.notificationUrl,
+    trialDays: request.trialDays ?? 0,
+  })
+
+  if (response.status >= 400) {
+    throw new Error(`Failed to create subscription: ${response.status} ${response.statusText} - ${response.body}`)
+  }
+
+  return JSON.parse(response.body)
+}
+
+/**
+ * Get a subscription by ID
+ */
+export async function getSubscription(subscriptionId: number): Promise<ShopRenterSubscription> {
+  const response = await makePartnerApiRequest(`/subscriptions/${subscriptionId}`, 'GET')
+
+  if (response.status >= 400) {
+    throw new Error(`Failed to get subscription: ${response.status} ${response.statusText}`)
+  }
+
+  return JSON.parse(response.body)
+}
+
+/**
+ * Cancel a subscription
+ */
+export async function cancelSubscription(subscriptionId: number): Promise<void> {
+  const response = await makePartnerApiRequest(`/subscriptions/${subscriptionId}`, 'DELETE')
+
+  if (response.status >= 400) {
+    throw new Error(`Failed to cancel subscription: ${response.status} ${response.statusText}`)
+  }
+}
+
+// =============================================================================
+// CONFIRMATION URL HELPER
+// =============================================================================
+
+/**
+ * Construct the confirmation URL for a subscription
+ *
+ * IMPORTANT: The Partner API returns an empty `paymentUrl` field.
+ * Use this function to construct the confirmation URL manually.
+ *
+ * The shop owner must visit this URL to confirm and pay for the subscription.
+ * They will be redirected to log in to their shop admin, then can confirm payment.
+ *
+ * @param shopname - The shop name (e.g., "myshop" from "myshop.shoprenter.hu")
+ * @param subscriptionId - The subscription ID returned from createSubscription
+ * @returns The confirmation URL the shop owner should visit
+ */
+export function buildConfirmationUrl(shopname: string, subscriptionId: number): string {
+  return `https://${shopname}.shoprenter.hu/admin/app/payment/recurring/${subscriptionId}`
+}
+
+// =============================================================================
+// STORE HELPER FUNCTIONS
+// =============================================================================
+
+/**
+ * Get store info and extract shopname
+ */
+export async function getStoreInfo(storeId: string): Promise<{
+  shopname: string
+  storeName: string
+  storeUrl: string
+  payerId?: number
+}> {
+  const supabaseUrl = Deno.env.get('SUPABASE_URL')!
+  const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+  const supabase = createClient(supabaseUrl, supabaseServiceKey)
+
+  const { data: store, error } = await supabase
+    .from('stores')
+    .select('store_name, store_url, alt_data')
+    .eq('id', storeId)
+    .eq('platform_name', 'shoprenter')
+    .single()
+
+  if (error || !store) {
+    throw new Error('ShopRenter store not found')
+  }
+
+  const shopname = store.store_name || extractShopname(store.store_url)
+  const payerId = store.alt_data?.shoprenter_payer_id
+
+  return {
+    shopname,
+    storeName: store.store_name,
+    storeUrl: store.store_url,
+    payerId,
+  }
+}
+
+/**
+ * Save payer ID to store's alt_data
+ */
+export async function savePayerIdToStore(storeId: string, payerId: number): Promise<void> {
+  const supabaseUrl = Deno.env.get('SUPABASE_URL')!
+  const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+  const supabase = createClient(supabaseUrl, supabaseServiceKey)
+
+  // Get current alt_data
+  const { data: store } = await supabase
+    .from('stores')
+    .select('alt_data')
+    .eq('id', storeId)
+    .single()
+
+  const altData = store?.alt_data || {}
+  altData.shoprenter_payer_id = payerId
+
+  const { error } = await supabase
+    .from('stores')
+    .update({ alt_data: altData })
+    .eq('id', storeId)
+
+  if (error) {
+    console.error('[ShopRenter Billing] Failed to save payer ID:', error)
+    throw new Error('Failed to save payer ID to store')
+  }
+}
+
+// =============================================================================
+// LEGACY API COMPATIBILITY
+// These functions maintain backwards compatibility with the old interface
+// =============================================================================
+
+/**
+ * @deprecated Use createSubscription instead
+ * Create a recurring charge using the new Partner API
+ */
+export async function createRecurringCharge(
+  storeId: string,
+  request: ShopRenterRecurringChargeRequest
+): Promise<ShopRenterChargeResponse> {
+  const storeInfo = await getStoreInfo(storeId)
+
+  if (!storeInfo.payerId) {
+    throw new Error('Store does not have a payer ID. Create a payer first using createPayer().')
+  }
+
+  const subscription = await createSubscription({
+    payerId: storeInfo.payerId,
+    planId: request.planId,
+    test: request.test ?? false,
+    successUrl: request.successUrl,
+    failedUrl: request.failedUrl,
+    notificationUrl: request.notificationUrl,
+    trialDays: request.trialDays,
+  })
+
+  // Build confirmation URL since paymentUrl is empty
+  const confirmationUrl = buildConfirmationUrl(storeInfo.shopname, subscription.id)
+
+  return {
+    id: subscription.id,
+    status: subscription.status,
+    netPrice: subscription.netPrice,
+    grossPrice: subscription.price?.grossAmount,
+    vatPrice: subscription.price?.vatAmount,
+    confirmationUrl,
+    createdAt: subscription.createdAt,
+    updatedAt: subscription.updatedAt,
+    planId: subscription.planId,
+    billingCycleLength: subscription.billingCycleLength,
+    billingCycleCount: subscription.billingCycleCount,
+    trialDays: subscription.trialDays,
+  }
+}
+
+/**
+ * @deprecated Use getSubscription instead
+ */
+export async function getRecurringCharge(
+  storeId: string,
+  chargeId: number
+): Promise<ShopRenterChargeResponse> {
+  const storeInfo = await getStoreInfo(storeId)
+  const subscription = await getSubscription(chargeId)
+  const confirmationUrl = buildConfirmationUrl(storeInfo.shopname, subscription.id)
+
+  return {
+    id: subscription.id,
+    status: subscription.status,
+    netPrice: subscription.netPrice,
+    grossPrice: subscription.price?.grossAmount,
+    vatPrice: subscription.price?.vatAmount,
+    confirmationUrl,
+    createdAt: subscription.createdAt,
+    updatedAt: subscription.updatedAt,
+    planId: subscription.planId,
+    billingCycleLength: subscription.billingCycleLength,
+    billingCycleCount: subscription.billingCycleCount,
+    trialDays: subscription.trialDays,
+  }
+}
+
+/**
+ * @deprecated Use cancelSubscription instead
+ */
+export async function cancelRecurringCharge(
+  _storeId: string,
+  chargeId: number
+): Promise<void> {
+  await cancelSubscription(chargeId)
+}
+
+/**
+ * @deprecated One-time charges are NOT available via Partner API
+ * Use subscriptions with billingCycleCount: 1 as a workaround
+ */
+export async function createOneTimeCharge(
+  _storeId: string,
+  _request: ShopRenterOneTimeChargeRequest
+): Promise<ShopRenterChargeResponse> {
+  throw new Error(
+    'One-time charges are not available via the Partner API. ' +
+    'Use a subscription with billingCycleCount: 1 as a workaround for single payments.'
+  )
+}
+
+// =============================================================================
+// WEBHOOK VERIFICATION
+// =============================================================================
+
+/**
+ * Helper to convert ArrayBuffer to hex string
+ */
+function bufferToHex(buffer: ArrayBuffer): string {
+  const byteArray = new Uint8Array(buffer)
+  return Array.from(byteArray)
+    .map((byte) => byte.toString(16).padStart(2, '0'))
+    .join('')
+}
+
+/**
+ * Verify webhook HMAC signature
+ *
+ * ShopRenter sends webhooks with:
+ * - Query param `hmac`: HMAC-SHA256 signature of the raw body
+ * - Body JSON containing `id`, `status`, `time`, `reason`
+ *
+ * @param rawBody - The raw request body (JSON string)
+ * @param receivedHmac - The HMAC from the query parameter
+ * @param webhookSecret - Your webhook secret key
+ * @returns true if signature is valid
+ */
+export async function verifyWebhookHmac(
+  rawBody: string,
+  receivedHmac: string,
+  webhookSecret: string
+): Promise<boolean> {
+  const encoder = new TextEncoder()
+  const keyData = encoder.encode(webhookSecret)
+
+  const key = await crypto.subtle.importKey(
+    'raw',
+    keyData,
+    { name: 'HMAC', hash: { name: 'SHA-256' } },
+    false,
+    ['sign']
+  )
+
+  const messageData = encoder.encode(rawBody)
+  const signature = await crypto.subtle.sign('HMAC', key, messageData)
+  const calculatedHmac = bufferToHex(signature)
+
+  console.log('[ShopRenter Billing] HMAC verification:')
+  console.log('  - Received:', receivedHmac)
+  console.log('  - Calculated:', calculatedHmac)
+
+  // Timing-safe comparison
+  if (receivedHmac.length !== calculatedHmac.length) {
+    return false
+  }
+
+  let result = 0
+  for (let i = 0; i < receivedHmac.length; i++) {
+    result |= receivedHmac.charCodeAt(i) ^ calculatedHmac.charCodeAt(i)
+  }
+
+  return result === 0
+}
+
+/**
+ * Validate webhook timestamp is recent (within 5 minutes)
+ * ShopRenter timestamps are in UTC
+ */
+export function validateWebhookTimestamp(webhookTime: number, maxAgeSeconds: number = 300): boolean {
+  const now = Math.floor(Date.now() / 1000)
+  const diff = Math.abs(now - webhookTime)
+
+  console.log('[ShopRenter Billing] Timestamp validation:')
+  console.log('  - Webhook time:', webhookTime, new Date(webhookTime * 1000).toISOString())
+  console.log('  - Current time:', now, new Date(now * 1000).toISOString())
+  console.log('  - Difference:', diff, 'seconds')
+
+  return diff <= maxAgeSeconds
+}
+
+/**
+ * Parse webhook payload
+ */
+export function parseWebhookPayload(rawBody: string): ShopRenterWebhookPayload {
+  const payload = JSON.parse(rawBody)
+
+  return {
+    id: payload.id,
+    status: payload.status,
+    time: payload.time,
+    reason: payload.reason ?? null,
+  }
+}
+
+// =============================================================================
+// HELPER FUNCTIONS
+// =============================================================================
+
+/**
+ * Get webhook secret from environment
+ */
+export function getWebhookSecret(): string {
+  const webhookSecret = Deno.env.get('SHOPRENTER_WEBHOOK_SECRET')
+  if (!webhookSecret) {
+    throw new Error('Missing SHOPRENTER_WEBHOOK_SECRET environment variable')
+  }
+  return webhookSecret
+}
+
+/**
+ * Extract shopname from store URL
+ * e.g., "https://myshop.shoprenter.hu" -> "myshop"
+ */
+export function extractShopname(storeUrl: string): string {
+  try {
+    const url = new URL(storeUrl)
+    const hostname = url.hostname
+
+    // Handle *.shoprenter.hu or *.myshoprenter.hu
+    if (hostname.endsWith('.shoprenter.hu') || hostname.endsWith('.myshoprenter.hu')) {
+      return hostname.split('.')[0]
+    }
+
+    throw new Error(`Invalid ShopRenter URL: ${storeUrl}`)
+  } catch (e) {
+    // Try without protocol
+    const match = storeUrl.match(/^([a-z0-9-]+)\.(my)?shoprenter\.hu/i)
+    if (match) {
+      return match[1]
+    }
+    throw new Error(`Cannot extract shopname from: ${storeUrl}`)
+  }
+}
+
+/**
+ * Build callback URLs for payment flow
+ */
+export function buildCallbackUrls(frontendUrl: string, chargeType: 'subscription' | 'topup', chargeId: number) {
+  const base = frontendUrl.replace(/\/$/, '')
+
+  return {
+    successUrl: `${base}/billing/success?type=${chargeType}&charge_id=${chargeId}`,
+    failedUrl: `${base}/billing/failed?type=${chargeType}&charge_id=${chargeId}`,
+  }
+}
+
+// =============================================================================
+// EXISTING PLANS (Pre-created in ShopRenter)
+// =============================================================================
+
+/**
+ * Known plan IDs that have been created in ShopRenter
+ * These can be used directly without creating new plans
+ */
+export const SHOPRENTER_PLANS = {
+  STARTER: 912,   // 8,990 HUF/month
+  GROWTH: 914,    // 24,900 HUF/month
+  SCALE: 915,     // 59,900 HUF/month
+} as const

+ 160 - 0
supabase/functions/shoprenter-cancel-subscription/index.ts

@@ -0,0 +1,160 @@
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
+import { cancelShopApiRecurringCharge, getStoreInfo } from '../_shared/shoprenter-billing-client.ts'
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+}
+
+serve(async (req) => {
+  if (req.method === 'OPTIONS') {
+    return new Response(null, { headers: corsHeaders })
+  }
+
+  console.log('[shoprenter-cancel-subscription] Request received')
+
+  try {
+    // Get authorization header
+    const authHeader = req.headers.get('Authorization')
+    if (!authHeader) {
+      return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
+        status: 401,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Initialize Supabase clients
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!
+    const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+
+    const supabaseUser = createClient(supabaseUrl, supabaseAnonKey, {
+      global: { headers: { Authorization: authHeader } },
+    })
+
+    const supabase = createClient(supabaseUrl, supabaseServiceKey)
+
+    // Get authenticated user
+    const { data: { user }, error: authError } = await supabaseUser.auth.getUser()
+    if (authError || !user) {
+      return new Response(JSON.stringify({ error: 'Unauthorized' }), {
+        status: 401,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Parse request body
+    const { store_id } = await req.json()
+
+    if (!store_id) {
+      return new Response(JSON.stringify({ error: 'Missing store_id' }), {
+        status: 400,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    console.log(`[shoprenter-cancel-subscription] Cancelling subscription for store ${store_id}`)
+
+    // Get store and verify ownership
+    const { data: store, error: storeError } = await supabase
+      .from('stores')
+      .select('*')
+      .eq('id', store_id)
+      .eq('user_id', user.id)
+      .single()
+
+    if (storeError || !store) {
+      return new Response(JSON.stringify({ error: 'Store not found or not owned by user' }), {
+        status: 404,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Get current subscription
+    const { data: subscription, error: subError } = await supabase
+      .from('store_subscriptions')
+      .select('*')
+      .eq('store_id', store_id)
+      .single()
+
+    if (subError || !subscription) {
+      return new Response(JSON.stringify({ error: 'No subscription found' }), {
+        status: 404,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Check if subscription has a ShopRenter provider
+    if (subscription.payment_provider !== 'shoprenter' || !subscription.provider_subscription_id) {
+      // No ShopRenter subscription to cancel - just update local status
+      await supabase
+        .from('store_subscriptions')
+        .update({
+          status: 'cancelled',
+          cancelled_at: new Date().toISOString(),
+          cancellation_reason: 'User cancelled',
+        })
+        .eq('id', subscription.id)
+
+      return new Response(JSON.stringify({
+        success: true,
+        message: 'Subscription cancelled locally (no ShopRenter subscription)',
+      }), {
+        status: 200,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Cancel on ShopRenter via Shop API
+    try {
+      const storeInfo = await getStoreInfo(store_id)
+      await cancelShopApiRecurringCharge(
+        storeInfo.shopname,
+        store_id,
+        parseInt(subscription.provider_subscription_id)
+      )
+      console.log('[shoprenter-cancel-subscription] Cancelled on ShopRenter')
+    } catch (cancelError) {
+      console.error('[shoprenter-cancel-subscription] Error cancelling on ShopRenter:', cancelError)
+      // Continue anyway - maybe it's already cancelled
+    }
+
+    // Update local subscription
+    await supabase
+      .from('store_subscriptions')
+      .update({
+        status: 'cancelled',
+        provider_status: 'cancelled',
+        cancelled_at: new Date().toISOString(),
+        cancellation_reason: 'User cancelled',
+      })
+      .eq('id', subscription.id)
+
+    // Log billing history
+    await supabase.from('billing_history').insert({
+      store_id,
+      subscription_id: subscription.id,
+      event_type: 'subscription_cancelled',
+      currency: subscription.currency || 'HUF',
+      amount: 0,
+      description: 'User cancelled subscription',
+    })
+
+    return new Response(JSON.stringify({
+      success: true,
+      message: 'Subscription cancelled successfully',
+    }), {
+      status: 200,
+      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+    })
+
+  } catch (error) {
+    console.error('[shoprenter-cancel-subscription] Error:', error)
+
+    return new Response(JSON.stringify({ error: error.message }), {
+      status: 500,
+      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+    })
+  }
+})

+ 191 - 0
supabase/functions/shoprenter-create-subscription/index.ts

@@ -0,0 +1,191 @@
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
+import {
+  createShopApiRecurringCharge,
+  getStoreInfo,
+  SHOPRENTER_PLANS,
+} from '../_shared/shoprenter-billing-client.ts'
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+}
+
+serve(async (req) => {
+  if (req.method === 'OPTIONS') {
+    return new Response(null, { headers: corsHeaders })
+  }
+
+  console.log('[shoprenter-create-subscription] Request received')
+
+  try {
+    // Get authorization header
+    const authHeader = req.headers.get('Authorization')
+    if (!authHeader) {
+      return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
+        status: 401,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Initialize Supabase clients
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!
+    const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+
+    // Client with user's token for auth
+    const supabaseUser = createClient(supabaseUrl, supabaseAnonKey, {
+      global: { headers: { Authorization: authHeader } },
+    })
+
+    // Service role client for database operations
+    const supabase = createClient(supabaseUrl, supabaseServiceKey)
+
+    // Get authenticated user
+    const { data: { user }, error: authError } = await supabaseUser.auth.getUser()
+    if (authError || !user) {
+      return new Response(JSON.stringify({ error: 'Unauthorized' }), {
+        status: 401,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Parse request body
+    const { store_id, plan_id, trial_days } = await req.json()
+
+    if (!store_id || !plan_id) {
+      return new Response(JSON.stringify({ error: 'Missing store_id or plan_id' }), {
+        status: 400,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    console.log(`[shoprenter-create-subscription] Creating subscription for store ${store_id}, plan ${plan_id}`)
+
+    // Get store and verify ownership
+    const { data: store, error: storeError } = await supabase
+      .from('stores')
+      .select('*, profiles:user_id(email, full_name, company_name)')
+      .eq('id', store_id)
+      .eq('user_id', user.id)
+      .single()
+
+    if (storeError || !store) {
+      return new Response(JSON.stringify({ error: 'Store not found or not owned by user' }), {
+        status: 404,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Verify store is ShopRenter
+    if (store.platform_name !== 'shoprenter') {
+      return new Response(JSON.stringify({ error: 'Store is not a ShopRenter store' }), {
+        status: 400,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Get ShopRenter plan ID
+    // If plan_id matches our known plans, use those directly
+    let shoprenterPlanId: number
+
+    if (plan_id === 'starter' || plan_id === SHOPRENTER_PLANS.STARTER) {
+      shoprenterPlanId = SHOPRENTER_PLANS.STARTER
+    } else if (plan_id === 'growth' || plan_id === SHOPRENTER_PLANS.GROWTH) {
+      shoprenterPlanId = SHOPRENTER_PLANS.GROWTH
+    } else if (plan_id === 'scale' || plan_id === SHOPRENTER_PLANS.SCALE) {
+      shoprenterPlanId = SHOPRENTER_PLANS.SCALE
+    } else {
+      // Try to look up from subscription_plans table
+      const { data: plan, error: planError } = await supabase
+        .from('subscription_plans')
+        .select('*')
+        .eq('id', plan_id)
+        .single()
+
+      if (planError || !plan) {
+        return new Response(JSON.stringify({ error: 'Plan not found' }), {
+          status: 404,
+          headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+        })
+      }
+
+      if (!plan.shoprenter_plan_id) {
+        return new Response(JSON.stringify({ error: 'Plan does not have a ShopRenter plan ID configured' }), {
+          status: 400,
+          headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+        })
+      }
+
+      shoprenterPlanId = plan.shoprenter_plan_id
+    }
+
+    // Get store info and extract shopname
+    const storeInfo = await getStoreInfo(store_id)
+    const shopname = storeInfo.shopname
+
+    // Get URLs for callbacks
+    const frontendUrl = Deno.env.get('FRONTEND_URL') || 'https://app.shopcall.ai'
+    const supabaseProjectUrl = Deno.env.get('SUPABASE_URL') || ''
+    const webhookUrl = `${supabaseProjectUrl}/functions/v1/shoprenter-payment-webhook`
+
+    // Determine if we should use test mode
+    const isTestMode = Deno.env.get('SHOPRENTER_TEST_MODE') === 'true'
+
+    console.log(`[shoprenter-create-subscription] Creating recurring charge for ${shopname}, plan ${shoprenterPlanId}`)
+
+    // Create recurring charge via Shop API (Bearer token auth)
+    const charge = await createShopApiRecurringCharge(shopname, store_id, {
+      planId: shoprenterPlanId,
+      notificationUrl: webhookUrl,
+      successUrl: `${frontendUrl}/billing/success?type=subscription`,
+      failedUrl: `${frontendUrl}/billing/failed?type=subscription`,
+      test: isTestMode,
+      trialDays: trial_days ?? 0,
+    })
+
+    console.log('[shoprenter-create-subscription] Recurring charge created:', charge)
+
+    // Store pending payment
+    const { error: pendingError } = await supabase.from('pending_shoprenter_payments').upsert({
+      store_id,
+      charge_type: 'subscription',
+      shoprenter_charge_id: charge.id,
+      shoprenter_plan_id: shoprenterPlanId,
+      currency: 'HUF',
+      net_price: charge.netPrice,
+      gross_price: charge.price?.grossAmount,
+      vat_price: charge.price?.vatAmount,
+      confirmation_url: charge.confirmationUrl,
+      status: 'pending',
+      is_test: isTestMode,
+    }, {
+      onConflict: 'store_id,shoprenter_charge_id',
+    })
+
+    if (pendingError) {
+      console.error('[shoprenter-create-subscription] Error storing pending payment:', pendingError)
+      // Don't fail - the charge was created, we just couldn't store it
+    }
+
+    return new Response(JSON.stringify({
+      success: true,
+      confirmation_url: charge.confirmationUrl,
+      charge_id: charge.id,
+      net_price: charge.netPrice,
+      gross_price: charge.price?.grossAmount,
+      status: charge.status,
+    }), {
+      status: 200,
+      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+    })
+
+  } catch (error) {
+    console.error('[shoprenter-create-subscription] Error:', error)
+
+    return new Response(JSON.stringify({ error: error.message }), {
+      status: 500,
+      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+    })
+  }
+})

+ 180 - 0
supabase/functions/shoprenter-create-topup/index.ts

@@ -0,0 +1,180 @@
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
+import {
+  createShopApiOneTimeCharge,
+  getStoreInfo,
+  extractShopname,
+} from '../_shared/shoprenter-billing-client.ts'
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+}
+
+serve(async (req) => {
+  if (req.method === 'OPTIONS') {
+    return new Response(null, { headers: corsHeaders })
+  }
+
+  console.log('[shoprenter-create-topup] Request received')
+
+  try {
+    // Get authorization header
+    const authHeader = req.headers.get('Authorization')
+    if (!authHeader) {
+      return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
+        status: 401,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Initialize Supabase clients
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!
+    const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+
+    const supabaseUser = createClient(supabaseUrl, supabaseAnonKey, {
+      global: { headers: { Authorization: authHeader } },
+    })
+
+    const supabase = createClient(supabaseUrl, supabaseServiceKey)
+
+    // Get authenticated user
+    const { data: { user }, error: authError } = await supabaseUser.auth.getUser()
+    if (authError || !user) {
+      return new Response(JSON.stringify({ error: 'Unauthorized' }), {
+        status: 401,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Parse request body
+    const { store_id, package_id } = await req.json()
+
+    if (!store_id || !package_id) {
+      return new Response(JSON.stringify({ error: 'Missing store_id or package_id' }), {
+        status: 400,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    console.log(`[shoprenter-create-topup] Creating top-up for store ${store_id}, package ${package_id}`)
+
+    // Get store and verify ownership
+    const { data: store, error: storeError } = await supabase
+      .from('stores')
+      .select('*, profiles:user_id(email, full_name, company_name)')
+      .eq('id', store_id)
+      .eq('user_id', user.id)
+      .single()
+
+    if (storeError || !store) {
+      return new Response(JSON.stringify({ error: 'Store not found or not owned by user' }), {
+        status: 404,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Verify store is ShopRenter
+    if (store.platform_name !== 'shoprenter') {
+      return new Response(JSON.stringify({ error: 'Store is not a ShopRenter store' }), {
+        status: 400,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Get package with translation
+    const { data: pkg, error: pkgError } = await supabase
+      .from('additional_packages')
+      .select(`
+        *,
+        translations:additional_package_translations(name, description, language_code)
+      `)
+      .eq('id', package_id)
+      .single()
+
+    if (pkgError || !pkg) {
+      return new Response(JSON.stringify({ error: 'Package not found' }), {
+        status: 404,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Get package name (prefer Hungarian for ShopRenter, fallback to English)
+    const huTranslation = pkg.translations?.find((t: any) => t.language_code === 'hu')
+    const enTranslation = pkg.translations?.find((t: any) => t.language_code === 'en')
+    const packageName = huTranslation?.name || enTranslation?.name || `ShopCall.ai - ${pkg.minutes} perc`
+
+    // Get store info and extract shopname
+    const storeInfo = await getStoreInfo(store_id)
+    const shopname = storeInfo.shopname
+
+    // Get URLs for callbacks
+    const frontendUrl = Deno.env.get('FRONTEND_URL') || 'https://app.shopcall.ai'
+    const supabaseProjectUrl = Deno.env.get('SUPABASE_URL') || ''
+    const webhookUrl = `${supabaseProjectUrl}/functions/v1/shoprenter-payment-webhook`
+
+    // Determine if we should use test mode
+    const isTestMode = Deno.env.get('SHOPRENTER_TEST_MODE') === 'true'
+
+    // Use HUF price (ShopRenter billing is in HUF)
+    const netPrice = Math.round(pkg.price_huf)
+
+    console.log(`[shoprenter-create-topup] Creating one-time charge for ${shopname}: ${packageName} - ${netPrice} HUF`)
+
+    // Create one-time charge via Shop API (Bearer token auth)
+    const charge = await createShopApiOneTimeCharge(shopname, store_id, {
+      name: packageName,
+      netPrice,
+      notificationUrl: webhookUrl,
+      successUrl: `${frontendUrl}/billing/success?type=topup`,
+      failedUrl: `${frontendUrl}/billing/failed?type=topup`,
+      test: isTestMode,
+    })
+
+    console.log('[shoprenter-create-topup] One-time charge created:', charge)
+
+    // Store pending payment
+    const { error: pendingError } = await supabase.from('pending_shoprenter_payments').upsert({
+      store_id,
+      charge_type: 'topup',
+      shoprenter_charge_id: charge.id,
+      package_id: pkg.id,
+      minutes_to_add: pkg.minutes,
+      currency: 'HUF',
+      net_price: charge.netPrice,
+      gross_price: charge.price?.grossAmount,
+      vat_price: charge.price?.vatAmount,
+      confirmation_url: charge.confirmationUrl,
+      status: 'pending',
+      is_test: isTestMode,
+    }, {
+      onConflict: 'store_id,shoprenter_charge_id',
+    })
+
+    if (pendingError) {
+      console.error('[shoprenter-create-topup] Error storing pending payment:', pendingError)
+      // Don't fail - the charge was created, we just couldn't store it
+    }
+
+    return new Response(JSON.stringify({
+      success: true,
+      confirmation_url: charge.confirmationUrl,
+      charge_id: charge.id,
+      net_price: charge.netPrice,
+      gross_price: charge.price?.grossAmount,
+      minutes: pkg.minutes,
+    }), {
+      status: 200,
+      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+    })
+
+  } catch (error) {
+    console.error('[shoprenter-create-topup] Error:', error)
+
+    return new Response(JSON.stringify({ error: error.message }), {
+      status: 500,
+      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+    })
+  }
+})

+ 176 - 0
supabase/functions/shoprenter-payment-status/index.ts

@@ -0,0 +1,176 @@
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+}
+
+serve(async (req) => {
+  if (req.method === 'OPTIONS') {
+    return new Response(null, { headers: corsHeaders })
+  }
+
+  console.log('[shoprenter-payment-status] Request received')
+
+  try {
+    // Get authorization header
+    const authHeader = req.headers.get('Authorization')
+    if (!authHeader) {
+      return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
+        status: 401,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Initialize Supabase clients
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!
+    const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+
+    const supabaseUser = createClient(supabaseUrl, supabaseAnonKey, {
+      global: { headers: { Authorization: authHeader } },
+    })
+
+    const supabase = createClient(supabaseUrl, supabaseServiceKey)
+
+    // Get authenticated user
+    const { data: { user }, error: authError } = await supabaseUser.auth.getUser()
+    if (authError || !user) {
+      return new Response(JSON.stringify({ error: 'Unauthorized' }), {
+        status: 401,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Parse request - can be query params (GET) or body (POST)
+    let store_id: string | null = null
+    let charge_id: number | null = null
+    let charge_type: string | null = null
+
+    if (req.method === 'GET') {
+      const url = new URL(req.url)
+      store_id = url.searchParams.get('store_id')
+      charge_id = url.searchParams.get('charge_id') ? parseInt(url.searchParams.get('charge_id')!) : null
+      charge_type = url.searchParams.get('type')
+    } else {
+      const body = await req.json()
+      store_id = body.store_id
+      charge_id = body.charge_id
+      charge_type = body.type || body.charge_type
+    }
+
+    // At minimum need store_id
+    if (!store_id) {
+      return new Response(JSON.stringify({ error: 'Missing store_id' }), {
+        status: 400,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    console.log(`[shoprenter-payment-status] Checking status for store ${store_id}, charge ${charge_id}`)
+
+    // Verify user owns the store
+    const { data: store, error: storeError } = await supabase
+      .from('stores')
+      .select('id')
+      .eq('id', store_id)
+      .eq('user_id', user.id)
+      .single()
+
+    if (storeError || !store) {
+      return new Response(JSON.stringify({ error: 'Store not found or not owned by user' }), {
+        status: 404,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Build response data
+    const response: any = {
+      store_id,
+    }
+
+    // If charge_id provided, look up specific pending payment
+    if (charge_id) {
+      const { data: pendingPayment, error: pendingError } = await supabase
+        .from('pending_shoprenter_payments')
+        .select('*')
+        .eq('store_id', store_id)
+        .eq('shoprenter_charge_id', charge_id)
+        .single()
+
+      if (!pendingError && pendingPayment) {
+        response.pending_payment = {
+          status: pendingPayment.status,
+          charge_type: pendingPayment.charge_type,
+          charge_id: pendingPayment.shoprenter_charge_id,
+          plan_id: pendingPayment.plan_id,
+          package_id: pendingPayment.package_id,
+          minutes_to_add: pendingPayment.minutes_to_add,
+          net_price: pendingPayment.net_price,
+          gross_price: pendingPayment.gross_price,
+          created_at: pendingPayment.created_at,
+          confirmed_at: pendingPayment.confirmed_at,
+        }
+      }
+    }
+
+    // Get current subscription status
+    const { data: subscription, error: subError } = await supabase
+      .from('store_subscriptions')
+      .select(`
+        *,
+        plan:subscription_plans(*)
+      `)
+      .eq('store_id', store_id)
+      .single()
+
+    if (!subError && subscription) {
+      response.subscription = {
+        status: subscription.status,
+        plan_code: subscription.plan?.plan_code,
+        plan_name: subscription.plan?.name,
+        billing_period: subscription.billing_period,
+        payment_provider: subscription.payment_provider,
+        provider_status: subscription.provider_status,
+        provider_subscription_id: subscription.provider_subscription_id,
+        period_start: subscription.period_start,
+        period_end: subscription.period_end,
+        minutes_used: subscription.minutes_used,
+        minutes_included: subscription.minutes_included,
+      }
+    }
+
+    // Get recent webhook logs for this store (last 5)
+    const { data: webhookLogs } = await supabase
+      .from('payment_webhook_logs')
+      .select('*')
+      .eq('store_id', store_id)
+      .order('created_at', { ascending: false })
+      .limit(5)
+
+    if (webhookLogs && webhookLogs.length > 0) {
+      response.recent_webhooks = webhookLogs.map(log => ({
+        charge_id: log.charge_id,
+        charge_type: log.charge_type,
+        status: log.status,
+        reason: log.reason,
+        created_at: log.created_at,
+        processing_result: log.processing_result,
+      }))
+    }
+
+    return new Response(JSON.stringify(response), {
+      status: 200,
+      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+    })
+
+  } catch (error) {
+    console.error('[shoprenter-payment-status] Error:', error)
+
+    return new Response(JSON.stringify({ error: error.message }), {
+      status: 500,
+      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+    })
+  }
+})

+ 447 - 0
supabase/functions/shoprenter-payment-webhook/index.ts

@@ -0,0 +1,447 @@
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
+import {
+  verifyWebhookHmac,
+  validateWebhookTimestamp,
+  parseWebhookPayload,
+  getWebhookSecret,
+  type ShopRenterWebhookPayload,
+} from '../_shared/shoprenter-billing-client.ts'
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+}
+
+serve(async (req) => {
+  // Handle CORS preflight
+  if (req.method === 'OPTIONS') {
+    return new Response(null, { headers: corsHeaders })
+  }
+
+  console.log('[shoprenter-payment-webhook] Received webhook request')
+
+  try {
+    // Get HMAC from query params
+    const url = new URL(req.url)
+    const receivedHmac = url.searchParams.get('hmac')
+
+    if (!receivedHmac) {
+      console.error('[shoprenter-payment-webhook] Missing HMAC parameter')
+      return new Response(JSON.stringify({ error: 'Missing HMAC parameter' }), {
+        status: 401,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Get raw body for HMAC verification
+    const rawBody = await req.text()
+    console.log('[shoprenter-payment-webhook] Raw body:', rawBody)
+
+    // Get webhook secret
+    let webhookSecret: string
+    try {
+      webhookSecret = getWebhookSecret()
+    } catch (e) {
+      console.error('[shoprenter-payment-webhook] SHOPRENTER_WEBHOOK_SECRET not configured')
+      return new Response(JSON.stringify({ error: 'Webhook secret not configured' }), {
+        status: 500,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Verify HMAC signature
+    const isValidHmac = await verifyWebhookHmac(rawBody, receivedHmac, webhookSecret)
+    if (!isValidHmac) {
+      console.error('[shoprenter-payment-webhook] Invalid HMAC signature')
+      return new Response(JSON.stringify({ error: 'Invalid HMAC signature' }), {
+        status: 401,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Parse webhook payload
+    const payload: ShopRenterWebhookPayload = parseWebhookPayload(rawBody)
+    console.log('[shoprenter-payment-webhook] Parsed payload:', payload)
+
+    // Validate timestamp (within 5 minutes)
+    if (!validateWebhookTimestamp(payload.time, 300)) {
+      console.warn('[shoprenter-payment-webhook] Webhook timestamp too old, but processing anyway')
+      // Don't reject - just log warning. Old webhooks may be retries.
+    }
+
+    // Initialize Supabase client with service role
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+    const supabase = createClient(supabaseUrl, supabaseServiceKey)
+
+    // Find the pending payment by charge ID
+    const { data: pendingPayment, error: pendingError } = await supabase
+      .from('pending_shoprenter_payments')
+      .select('*')
+      .eq('shoprenter_charge_id', payload.id)
+      .single()
+
+    if (pendingError && pendingError.code !== 'PGRST116') {
+      console.error('[shoprenter-payment-webhook] Error finding pending payment:', pendingError)
+    }
+
+    // Determine charge type and store from pending payment or existing subscription
+    let storeId: string | null = null
+    let chargeType: 'subscription' | 'topup' = 'subscription'
+
+    if (pendingPayment) {
+      storeId = pendingPayment.store_id
+      chargeType = pendingPayment.charge_type as 'subscription' | 'topup'
+    } else {
+      // Try to find by provider_subscription_id in store_subscriptions
+      const { data: subscription } = await supabase
+        .from('store_subscriptions')
+        .select('store_id')
+        .eq('provider_subscription_id', payload.id.toString())
+        .single()
+
+      if (subscription) {
+        storeId = subscription.store_id
+        chargeType = 'subscription'
+      }
+    }
+
+    // Log webhook to database
+    await supabase.from('payment_webhook_logs').insert({
+      store_id: storeId,
+      provider: 'shoprenter',
+      charge_type: chargeType,
+      charge_id: payload.id,
+      status: payload.status,
+      reason: payload.reason,
+      webhook_time: payload.time,
+      raw_payload: JSON.parse(rawBody),
+      processing_result: 'processing',
+    })
+
+    if (!storeId) {
+      console.error('[shoprenter-payment-webhook] Cannot find store for charge ID:', payload.id)
+      // Still return 200 to acknowledge webhook
+      return new Response(JSON.stringify({ success: true, message: 'Webhook acknowledged, store not found' }), {
+        status: 200,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+      })
+    }
+
+    // Handle different statuses
+    console.log(`[shoprenter-payment-webhook] Processing status: ${payload.status} for charge ${payload.id}`)
+
+    switch (payload.status) {
+      case 'active':
+        await handleActiveStatus(supabase, storeId, payload, pendingPayment, chargeType)
+        break
+
+      case 'declined':
+      case 'failed':
+        await handleFailedStatus(supabase, storeId, payload, pendingPayment, chargeType)
+        break
+
+      case 'frozen':
+        await handleFrozenStatus(supabase, storeId, payload)
+        break
+
+      case 'cancelled':
+        await handleCancelledStatus(supabase, storeId, payload)
+        break
+
+      case 'expired':
+        await handleExpiredStatus(supabase, storeId, payload)
+        break
+
+      case 'pending':
+      case 'accepted':
+        // Intermediate states - just log
+        console.log(`[shoprenter-payment-webhook] Intermediate status: ${payload.status}`)
+        break
+
+      default:
+        console.warn(`[shoprenter-payment-webhook] Unknown status: ${payload.status}`)
+    }
+
+    // Update webhook log as processed
+    await supabase
+      .from('payment_webhook_logs')
+      .update({ processing_result: 'success' })
+      .eq('charge_id', payload.id)
+      .eq('webhook_time', payload.time)
+
+    return new Response(JSON.stringify({ success: true }), {
+      status: 200,
+      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+    })
+
+  } catch (error) {
+    console.error('[shoprenter-payment-webhook] Error processing webhook:', error)
+
+    return new Response(JSON.stringify({ error: error.message }), {
+      status: 500,
+      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+    })
+  }
+})
+
+// =============================================================================
+// STATUS HANDLERS
+// =============================================================================
+
+async function handleActiveStatus(
+  supabase: ReturnType<typeof createClient>,
+  storeId: string,
+  payload: ShopRenterWebhookPayload,
+  pendingPayment: any,
+  chargeType: 'subscription' | 'topup'
+) {
+  console.log(`[shoprenter-payment-webhook] Handling ACTIVE status for ${chargeType}`)
+
+  if (chargeType === 'subscription' && pendingPayment) {
+    // Activate subscription
+    const { error: subError } = await supabase
+      .from('store_subscriptions')
+      .update({
+        status: 'active',
+        billing_period: 'monthly',
+        plan_id: pendingPayment.plan_id,
+        payment_provider: 'shoprenter',
+        provider_subscription_id: payload.id.toString(),
+        provider_status: 'active',
+        provider_status_reason: null,
+        provider_last_webhook_at: new Date().toISOString(),
+        period_start: new Date().toISOString(),
+        period_end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // +30 days
+        minutes_used: 0, // Reset usage for new billing period
+      })
+      .eq('store_id', storeId)
+
+    if (subError) {
+      console.error('[shoprenter-payment-webhook] Error updating subscription:', subError)
+      throw subError
+    }
+
+    // Log billing history
+    await supabase.from('billing_history').insert({
+      store_id: storeId,
+      event_type: 'subscription_started',
+      currency: pendingPayment.currency,
+      amount: pendingPayment.gross_price || pendingPayment.net_price,
+      plan_id: pendingPayment.plan_id,
+      description: `Subscription activated via ShopRenter payment`,
+      metadata: {
+        shoprenter_charge_id: payload.id,
+        net_price: pendingPayment.net_price,
+        gross_price: pendingPayment.gross_price,
+      },
+    })
+
+    // Update pending payment status
+    await supabase
+      .from('pending_shoprenter_payments')
+      .update({
+        status: 'confirmed',
+        confirmed_at: new Date().toISOString(),
+      })
+      .eq('id', pendingPayment.id)
+
+    console.log('[shoprenter-payment-webhook] Subscription activated successfully')
+
+  } else if (chargeType === 'topup' && pendingPayment) {
+    // Complete top-up purchase
+    // Get current subscription
+    const { data: subscription } = await supabase
+      .from('store_subscriptions')
+      .select('id, period_end')
+      .eq('store_id', storeId)
+      .single()
+
+    if (!subscription) {
+      console.error('[shoprenter-payment-webhook] No subscription found for top-up')
+      return
+    }
+
+    // Insert package purchase
+    const { error: purchaseError } = await supabase.from('package_purchases').insert({
+      store_id: storeId,
+      subscription_id: subscription.id,
+      package_id: pendingPayment.package_id,
+      minutes_purchased: pendingPayment.minutes_to_add,
+      minutes_remaining: pendingPayment.minutes_to_add,
+      currency: pendingPayment.currency,
+      price_paid: pendingPayment.gross_price || pendingPayment.net_price,
+      is_auto_purchase: false,
+      expires_at: subscription.period_end,
+      shoprenter_charge_id: payload.id,
+      payment_status: 'completed',
+      payment_completed_at: new Date().toISOString(),
+      is_active: true,
+    })
+
+    if (purchaseError) {
+      console.error('[shoprenter-payment-webhook] Error inserting package purchase:', purchaseError)
+      throw purchaseError
+    }
+
+    // Log billing history
+    await supabase.from('billing_history').insert({
+      store_id: storeId,
+      subscription_id: subscription.id,
+      event_type: 'package_purchased',
+      currency: pendingPayment.currency,
+      amount: pendingPayment.gross_price || pendingPayment.net_price,
+      package_id: pendingPayment.package_id,
+      description: `Purchased ${pendingPayment.minutes_to_add} minutes via ShopRenter payment`,
+      metadata: {
+        shoprenter_charge_id: payload.id,
+        minutes_added: pendingPayment.minutes_to_add,
+      },
+    })
+
+    // Update pending payment status
+    await supabase
+      .from('pending_shoprenter_payments')
+      .update({
+        status: 'confirmed',
+        confirmed_at: new Date().toISOString(),
+      })
+      .eq('id', pendingPayment.id)
+
+    console.log('[shoprenter-payment-webhook] Top-up purchase completed successfully')
+
+  } else if (chargeType === 'subscription') {
+    // Renewal - subscription already exists, just update status
+    await supabase
+      .from('store_subscriptions')
+      .update({
+        provider_status: 'active',
+        provider_status_reason: null,
+        provider_last_webhook_at: new Date().toISOString(),
+        status: 'active',
+        period_start: new Date().toISOString(),
+        period_end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
+        minutes_used: 0,
+      })
+      .eq('store_id', storeId)
+
+    console.log('[shoprenter-payment-webhook] Subscription renewed successfully')
+  }
+}
+
+async function handleFailedStatus(
+  supabase: ReturnType<typeof createClient>,
+  storeId: string,
+  payload: ShopRenterWebhookPayload,
+  pendingPayment: any,
+  chargeType: 'subscription' | 'topup'
+) {
+  console.log(`[shoprenter-payment-webhook] Handling FAILED/DECLINED status for ${chargeType}`)
+
+  // Update pending payment if exists
+  if (pendingPayment) {
+    await supabase
+      .from('pending_shoprenter_payments')
+      .update({
+        status: payload.status,
+      })
+      .eq('id', pendingPayment.id)
+  }
+
+  // Update subscription provider status if applicable
+  if (chargeType === 'subscription') {
+    await supabase
+      .from('store_subscriptions')
+      .update({
+        provider_status: payload.status,
+        provider_status_reason: payload.reason,
+        provider_last_webhook_at: new Date().toISOString(),
+      })
+      .eq('store_id', storeId)
+  }
+}
+
+async function handleFrozenStatus(
+  supabase: ReturnType<typeof createClient>,
+  storeId: string,
+  payload: ShopRenterWebhookPayload
+) {
+  console.log('[shoprenter-payment-webhook] Handling FROZEN status (payment failed, retrying)')
+
+  // Update subscription to past_due
+  await supabase
+    .from('store_subscriptions')
+    .update({
+      status: 'past_due',
+      provider_status: 'frozen',
+      provider_status_reason: payload.reason || 'Payment failed - retrying for 15 days',
+      provider_last_webhook_at: new Date().toISOString(),
+    })
+    .eq('store_id', storeId)
+
+  // Log billing event
+  await supabase.from('billing_history').insert({
+    store_id: storeId,
+    event_type: 'payment_failed',
+    currency: 'HUF',
+    amount: 0,
+    description: `Payment failed: ${payload.reason || 'Unknown reason'}`,
+    metadata: {
+      shoprenter_charge_id: payload.id,
+      reason: payload.reason,
+    },
+  })
+}
+
+async function handleCancelledStatus(
+  supabase: ReturnType<typeof createClient>,
+  storeId: string,
+  payload: ShopRenterWebhookPayload
+) {
+  console.log('[shoprenter-payment-webhook] Handling CANCELLED status')
+
+  // Update subscription to cancelled
+  await supabase
+    .from('store_subscriptions')
+    .update({
+      status: 'cancelled',
+      provider_status: 'cancelled',
+      provider_status_reason: payload.reason,
+      provider_last_webhook_at: new Date().toISOString(),
+      cancelled_at: new Date().toISOString(),
+      cancellation_reason: payload.reason || 'Cancelled via ShopRenter',
+    })
+    .eq('store_id', storeId)
+
+  // Log billing event
+  await supabase.from('billing_history').insert({
+    store_id: storeId,
+    event_type: 'subscription_cancelled',
+    currency: 'HUF',
+    amount: 0,
+    description: `Subscription cancelled: ${payload.reason || 'No reason provided'}`,
+    metadata: {
+      shoprenter_charge_id: payload.id,
+      reason: payload.reason,
+    },
+  })
+}
+
+async function handleExpiredStatus(
+  supabase: ReturnType<typeof createClient>,
+  storeId: string,
+  payload: ShopRenterWebhookPayload
+) {
+  console.log('[shoprenter-payment-webhook] Handling EXPIRED status')
+
+  // Update subscription to expired
+  await supabase
+    .from('store_subscriptions')
+    .update({
+      status: 'expired',
+      provider_status: 'expired',
+      provider_last_webhook_at: new Date().toISOString(),
+    })
+    .eq('store_id', storeId)
+}