Files
flatrender/backend/db/migrations/31_payment_broker.sql
T
soroush.asadi ec51e87d2d feat(payment): standalone ZarinPal broker on pay.flatrender.ir
A generic multi-client payment gateway so FlatRender, meezi.ir and
bargevasat.ir can all pay through ZarinPal's single verified callback
domain (pay.flatrender.ir).

New Go service services/payment (clones the notification skeleton +
vendored deps):
- migration 31_payment_broker.sql — `payment` schema: client_apps,
  transactions, webhook_deliveries.
- ZarinPal v4 client ported from the proven identity PaymentService
  (request.json -> StartPay -> verify.json; codes 100/101).
- client API: POST /v1/pay/request + /v1/pay/inquiry, authed by
  X-Api-Key + HMAC body signature; GET /callback/zarinpal (the single
  verified endpoint) verifies, then 302s the user back to the site's
  return_url (signed) and fires a signed, retried webhook.
- per-client ZarinPal merchant override (default = shared merchant);
  amount stored canonically in Rial, unit to ZarinPal env-configurable.
- admin API /v1/admin/* (FlatRender admin JWT): client-app CRUD +
  key issue/rotate + transactions list.

Deploy wiring: payment-svc in docker-compose.v2.yml (host port 1607),
pay.flatrender.ir server block in mirror-nginx conf, ENV_FILE +
README updates (cert SAN + manual migration note).

Admin UI: src/components/admin/PaymentsAdmin.tsx (client apps with
one-time key reveal + rotate, transactions table) + /admin/payments
page + nav link + fa/en strings; pay-admin proxy route to payment-svc.

Docs/SDK: deploy/PAYMENTS.md (integration contract) + deploy/sdk/flatpay.js
(zero-dep Node client + webhook verifier) for meezi/any site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:59:54 +03:30

112 lines
5.8 KiB
SQL

-- =====================================================================
-- PAYMENT BROKER SCHEMA — generic multi-client ZarinPal gateway
-- Served on pay.flatrender.ir (the single ZarinPal-verified callback domain).
-- Other sites (meezi.ir, bargevasat.ir, FlatRender) register as client_apps
-- and route payments through this broker.
--
-- NOTE: migrations auto-run only on FIRST volume creation. On an existing
-- DB volume, apply this manually:
-- docker exec -i fr2-postgres psql -U postgres -d flatrender < 31_payment_broker.sql
-- =====================================================================
CREATE SCHEMA IF NOT EXISTS payment;
SET search_path TO payment, public;
-- ---------------------------------------------------------------------
-- client_apps — each site that pays through the broker
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS payment.client_apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID, -- optional link to identity.tenants
name TEXT NOT NULL, -- "meezi.ir", "FlatRender"
slug TEXT NOT NULL UNIQUE, -- "meezi"
api_key TEXT NOT NULL UNIQUE, -- public id (pk_...)
secret TEXT NOT NULL, -- shared HMAC secret (sk_...) — signs in+out
-- ZarinPal: per-client override; NULL → broker default merchant/sandbox
zarinpal_merchant_id TEXT,
zarinpal_sandbox BOOLEAN,
allowed_return_origins TEXT[] NOT NULL DEFAULT '{}', -- e.g. {'https://meezi.ir'}; empty = permissive
webhook_url TEXT, -- server-to-server result notification
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ---------------------------------------------------------------------
-- transactions — one row per payment attempt
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS payment.transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_app_id UUID NOT NULL REFERENCES payment.client_apps(id) ON DELETE RESTRICT,
status TEXT NOT NULL DEFAULT 'Created', -- Created|Pending|Paid|Failed|Cancelled|Expired
gateway TEXT NOT NULL DEFAULT 'ZarinPal',
amount_rial BIGINT NOT NULL, -- canonical Rial
currency TEXT NOT NULL DEFAULT 'IRR',
description TEXT,
client_ref TEXT, -- the client's own order id
return_url TEXT NOT NULL, -- where the user is sent back
metadata JSONB, -- echoed back to the client
payer_mobile TEXT,
payer_email TEXT,
authority TEXT, -- ZarinPal authority token
ref_id TEXT, -- ZarinPal ref_id (receipt)
card_pan TEXT,
fee_rial BIGINT,
gateway_response JSONB,
failure_reason TEXT,
paid_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pay_txn_client ON payment.transactions(client_app_id, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_pay_txn_authority ON payment.transactions(authority) WHERE authority IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_pay_txn_clientref ON payment.transactions(client_app_id, client_ref) WHERE client_ref IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_pay_txn_status ON payment.transactions(status);
-- ---------------------------------------------------------------------
-- webhook_deliveries — outbound signed notifications with retry
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS payment.webhook_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transaction_id UUID NOT NULL REFERENCES payment.transactions(id) ON DELETE CASCADE,
url TEXT NOT NULL,
payload JSONB NOT NULL,
signature TEXT NOT NULL,
attempts INT NOT NULL DEFAULT 0,
delivered BOOLEAN NOT NULL DEFAULT FALSE,
last_status INT,
last_error TEXT,
next_attempt_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pay_wh_pending ON payment.webhook_deliveries(delivered, next_attempt_at) WHERE delivered = FALSE;
-- ---------------------------------------------------------------------
-- updated_at triggers (helper tg_set_updated_at() created in 00_setup.sql)
-- ---------------------------------------------------------------------
DROP TRIGGER IF EXISTS tg_pay_client_apps_updated ON payment.client_apps;
CREATE TRIGGER tg_pay_client_apps_updated BEFORE UPDATE ON payment.client_apps
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
DROP TRIGGER IF EXISTS tg_pay_transactions_updated ON payment.transactions;
CREATE TRIGGER tg_pay_transactions_updated BEFORE UPDATE ON payment.transactions
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
DROP TRIGGER IF EXISTS tg_pay_webhook_updated ON payment.webhook_deliveries;
CREATE TRIGGER tg_pay_webhook_updated BEFORE UPDATE ON payment.webhook_deliveries
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();