|
|
@@ -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
|