feat(payment): admin-editable ZarinPal settings + in-panel test payment
Lets the broker's ZarinPal merchant / sandbox / amount-unit be set from Admin → درگاه پرداخت (persisted in payment.settings) instead of env + redeploy, and adds a per-app "test payment" button that mints a real ZarinPal StartPay link straight from the panel — no site wiring needed. - migration 33_payment_settings.sql: singleton payment.settings + a transactions.is_test column. (33, not 32 — 32 is content_render_engine.) - broker read-path precedence: per-client override > DB settings > env. - POST /v1/admin/clients/:id/test-payment + GET/PUT /v1/admin/settings. - admin UI: «تنظیمات زرینپال» tab + «پرداخت آزمایشی» button. Adversarial-review fixes (2 confirmed HIGH): - do NOT pre-seed the settings row — a seeded sandbox=TRUE default would override a production ZARINPAL_SANDBOX=false env and silently route real payments to sandbox.zarinpal.com until an admin untouched the toggle. No row → env governs until an admin saves. - test transactions are tagged is_test and the webhook dispatcher skips them, so an admin smoke-test can never notify (or credit) a real client, regardless of metadata. Broker-authoritative, not consumer-dependent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,35 @@ func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
|
||||
|
||||
// ── Global settings (singleton row id=1) ─────────────────────────────────────
|
||||
|
||||
func (s *Store) GetSettings(ctx context.Context) (*models.Settings, error) {
|
||||
var st models.Settings
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT zarinpal_merchant_id, zarinpal_sandbox, zarinpal_amount_unit, updated_at
|
||||
FROM payment.settings WHERE id = 1`).Scan(
|
||||
&st.ZarinPalMerchantID, &st.ZarinPalSandbox, &st.ZarinPalAmountUnit, &st.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &st, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSettings(ctx context.Context, merchant string, sandbox bool, unit string) (*models.Settings, error) {
|
||||
var st models.Settings
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO payment.settings (id, zarinpal_merchant_id, zarinpal_sandbox, zarinpal_amount_unit)
|
||||
VALUES (1, $1, $2, $3)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
zarinpal_merchant_id = EXCLUDED.zarinpal_merchant_id,
|
||||
zarinpal_sandbox = EXCLUDED.zarinpal_sandbox,
|
||||
zarinpal_amount_unit = EXCLUDED.zarinpal_amount_unit
|
||||
RETURNING zarinpal_merchant_id, zarinpal_sandbox, zarinpal_amount_unit, updated_at`,
|
||||
merchant, sandbox, unit).Scan(
|
||||
&st.ZarinPalMerchantID, &st.ZarinPalSandbox, &st.ZarinPalAmountUnit, &st.UpdatedAt)
|
||||
return &st, err
|
||||
}
|
||||
|
||||
// ── Client apps ───────────────────────────────────────────────────────────────
|
||||
|
||||
const clientCols = `id, tenant_id, name, slug, api_key, secret,
|
||||
@@ -133,7 +162,7 @@ func (s *Store) DeleteClientApp(ctx context.Context, id uuid.UUID) error {
|
||||
const txnCols = `id, client_app_id, status, gateway, amount_rial, currency, description,
|
||||
client_ref, return_url, metadata, payer_mobile, payer_email,
|
||||
authority, ref_id, card_pan, fee_rial, gateway_response, failure_reason,
|
||||
paid_at, failed_at, expires_at, created_at, updated_at`
|
||||
paid_at, failed_at, expires_at, created_at, updated_at, is_test`
|
||||
|
||||
func scanTxn(row pgx.Row) (*models.Transaction, error) {
|
||||
var t models.Transaction
|
||||
@@ -142,7 +171,7 @@ func scanTxn(row pgx.Row) (*models.Transaction, error) {
|
||||
&t.ID, &t.ClientAppID, &t.Status, &t.Gateway, &t.AmountRial, &t.Currency, &t.Description,
|
||||
&t.ClientRef, &t.ReturnURL, &meta, &t.PayerMobile, &t.PayerEmail,
|
||||
&t.Authority, &t.RefID, &t.CardPan, &t.FeeRial, &gwResp, &t.FailureReason,
|
||||
&t.PaidAt, &t.FailedAt, &t.ExpiresAt, &t.CreatedAt, &t.UpdatedAt,
|
||||
&t.PaidAt, &t.FailedAt, &t.ExpiresAt, &t.CreatedAt, &t.UpdatedAt, &t.IsTest,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -159,11 +188,11 @@ func (s *Store) CreateTransaction(ctx context.Context, t *models.Transaction) (*
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO payment.transactions
|
||||
(client_app_id, status, gateway, amount_rial, currency, description,
|
||||
client_ref, return_url, metadata, payer_mobile, payer_email, expires_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
client_ref, return_url, metadata, payer_mobile, payer_email, expires_at, is_test)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
RETURNING `+txnCols,
|
||||
t.ClientAppID, t.Status, t.Gateway, t.AmountRial, t.Currency, t.Description,
|
||||
t.ClientRef, t.ReturnURL, meta, t.PayerMobile, t.PayerEmail, t.ExpiresAt)
|
||||
t.ClientRef, t.ReturnURL, meta, t.PayerMobile, t.PayerEmail, t.ExpiresAt, t.IsTest)
|
||||
return scanTxn(row)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user