diff --git a/.env.example b/.env.example index 1e54f7c..44f3fe3 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,10 @@ PAYMENT_QUARANTINE=24h # PocketBase request rate limits PAYGATE_RATE_LIMITS_ENABLED=true +# Public browser checkout API. Empty keeps /api/checkout/v2 disabled. +# Configure exact HTTPS origins only when the static checkout is ready to cut over. +PAYGATE_CHECKOUT_ORIGINS= + # Optional Google Messages connector GMESSAGES_ENABLED=false # GMESSAGES_SESSION_PATH=/app/pb_data/gmessages/session.json diff --git a/cmd/payment-api/main.go b/cmd/payment-api/main.go index 2ca2096..c8696a3 100644 --- a/cmd/payment-api/main.go +++ b/cmd/payment-api/main.go @@ -8,6 +8,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "time" @@ -54,6 +55,9 @@ func main() { HideStartBanner: false, }) migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{Automigrate: false}) + if args, changed := withServeOrigins(os.Args[1:], cfg.CheckoutAllowedOrigins); changed { + app.RootCmd.SetArgs(args) + } zeroLogger := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}).With().Timestamp().Logger() gmessagesLogger := gmessages.ProductionLogger(zeroLogger) @@ -168,7 +172,7 @@ func main() { if relayErr != nil { stdLogger.Error("relay health check failed", "error", relayErr) } else if !relayStatus.Ready { - _, _, _ = alertService.Open(alerts.Input{ + _, _, _ = alertService.EnsureOpen(alerts.Input{ Kind: "relay_unavailable", Severity: "warning", DedupeKey: "relay:paytm", Message: "Paytm verification relay is unavailable; new Paytm checkouts are blocked.", Details: relayStatus, }) @@ -322,3 +326,24 @@ func registerBackupCommands(app *pocketbase.PocketBase, service *backups.Service } app.RootCmd.AddCommand(createCmd, verifyCmd, restoreDrillCmd) } + +func withServeOrigins(args, origins []string) ([]string, bool) { + if len(origins) == 0 { + return args, false + } + hasServe := false + for _, arg := range args { + if arg == "serve" { + hasServe = true + } + if arg == "--origins" || strings.HasPrefix(arg, "--origins=") { + return args, false + } + } + if !hasServe { + return args, false + } + result := append([]string(nil), args...) + result = append(result, "--origins="+strings.Join(origins, ",")) + return result, true +} diff --git a/cmd/payment-api/main_test.go b/cmd/payment-api/main_test.go index ec1c972..ce7196d 100644 --- a/cmd/payment-api/main_test.go +++ b/cmd/payment-api/main_test.go @@ -2,6 +2,8 @@ package main import ( "context" + "slices" + "strings" "testing" "time" @@ -54,3 +56,26 @@ func TestStartBackgroundRunnersStartsEveryRunner(t *testing.T) { } } } + +func TestWithServeOriginsInjectsCheckoutAllowlist(t *testing.T) { + want := []string{"https://payment.mulearnscet.in", "https://pay.ieeesahrdaya.com"} + got, changed := withServeOrigins([]string{"serve", "--http=127.0.0.1:3000"}, want) + if !changed { + t.Fatal("expected serve origins injection") + } + if !slices.Contains(got, "--origins="+strings.Join(want, ",")) { + t.Fatalf("args=%v", got) + } +} + +func TestWithServeOriginsPreservesExplicitOrNonServeArgs(t *testing.T) { + for _, tc := range [][]string{ + {"serve", "--origins=https://custom.example"}, + {"migrate", "up"}, + } { + got, changed := withServeOrigins(tc, []string{"https://payment.example.com"}) + if changed || !slices.Equal(got, tc) { + t.Fatalf("args=%v got=%v changed=%v", tc, got, changed) + } + } +} diff --git a/docs/v2/00_MASTER_PLAN.md b/docs/v2/00_MASTER_PLAN.md new file mode 100644 index 0000000..7430869 --- /dev/null +++ b/docs/v2/00_MASTER_PLAN.md @@ -0,0 +1,123 @@ +# PayGate v2 — Master Rewrite Plan + +Status: implementation branch, not production +Canonical branches: +- API: `rewrite/paygate-v2-foundation` +- Checkout web: `rewrite/paygate-v2-ui` +- Android: `rewrite/paygate-v2-mobile` + +## Goal + +Rewrite PayGate into a smaller, typed, modular payment system without weakening any existing financial invariant. The rewrite must reduce framework coupling, duplicated evidence logic, duplicated Razorpay code, stringly-typed status handling, oversized transactions, UI complexity, and operator friction. + +PayGate v2 remains a modular monolith. It does **not** introduce microservices, Kafka, Redis, distributed transactions, or a second primary database simply for architectural fashion. + +## Non-negotiable payment invariants + +1. Money is stored and compared as integer paise only. +2. Direct-UPI matching is exact amount + payment account + occurrence-time constrained. +3. Evidence identity is globally unique where the source provides a stable bank/reference identity. +4. Notification evidence must have a unique non-empty signed evidence reference. +5. Two or more plausible payment candidates is always an ambiguous/fail-closed result. +6. Evidence predating payment creation beyond the accepted tolerance can never pay that payment. +7. Amount fingerprints remain quarantined after expiry, cancellation, payment, or late payment. +8. Idempotency replay is resolved before new-payment readiness checks. +9. Payment mutation and durable outbound event creation are atomic. +10. External network calls never happen inside payment database transactions. +## Product surfaces + +PayGate v2 is one product with three coordinated surfaces: + +### 1. Checkout web +A public, mobile-first payment experience whose only job is to create and complete a payment safely. It should expose the minimum number of decisions, use progressive disclosure, and make the exact amount and payment state unmistakable. + +### 2. Operator web +A typed administrative console for payments, reviews, refunds, reconciliation, alerts, relay health, capacity, backups, and settings. It must stop querying PocketBase collections directly. + +### 3. PayGate Android app +The existing relay becomes the PayGate mobile application. The proven notification listener, durable local queue, WorkManager recovery, boot receiver, foreground runtime service, wake-lock-bounded delivery, and signed relay protocol remain. The UI becomes a modern operator app for health, payments, reviews and alerts. + +## Architectural direction + +The target dependency direction is: + +`HTTP / source adapters -> application services -> typed domain -> repositories/UoW -> SQLite adapter` + +Business code must not depend on PocketBase `core.Record`, record string keys, or `core.App` transaction objects. PocketBase may remain temporarily behind repository adapters during migration. + +All payment evidence sources normalize into one domain `Evidence` model and one matching engine. SMS, authenticated email, Paytm notification, reconciliation and future sources are adapters, not parallel payment engines. +## Rewrite workstreams + +### A. Domain and persistence +- Introduce typed `Money`, `Payment`, `Evidence`, `EvidenceRef`, `MatchOutcome`, `Refund`, `RelayHealth`, and typed status/ID values. +- Add repository interfaces and an explicit unit-of-work abstraction. +- Move PocketBase record mapping behind storage adapters. +- Remove application-layer `...InApp` variants as repository/UoW adoption reaches each module. +- Keep SQLite and WAL unless measured load demonstrates a real need to change. + +### B. Evidence engine +- Normalize Kotak SMS, Slice email and Paytm notifications into one `Evidence` structure. +- Use one candidate selection algorithm for point-in-time and time-window evidence. +- Make manual review use the same matcher invariants with an operator-selected candidate. +- Preserve raw source events separately from normalized evidence and redact them by retention policy. +- Shadow-test Google Messages Android notification evidence against the server-side libgm connector before any connector removal. The Android path is observation-only and stores only non-raw parse metadata plus a hashed reference for correlation. +- QR pairing is retired from operator-facing HTTP/UI paths during the shadow period; Google-account pairing/reauth remains available until the libgm exit gate is satisfied. + +### C. Durable delivery and alerts +- Keep the payment outbox concept. +- Consolidate webhook and operator notification retry/claim/lease mechanics into one durable delivery engine. +- Separate discrete alert events from persistent alert conditions. +- Replace periodic `Open()` calls for conditions with `EnsureCondition()` semantics that do not increment occurrence counts every scan. +- Persist health-condition timing instead of relying on process-local maturation maps. +### D. Payment lifecycle and jobs +- Stop running global expiry mutations as a side effect of ordinary reads and unrelated writes. +- Expire due payments in small bounded background batches. +- Let matching determine on-time versus late from persisted timestamps, not from whether an expiry cron happened first. +- Preserve `reuse_after` as the fingerprint allocation guard. +- Batch reconciliation persistence/classification instead of holding one write transaction across a large statement. +- Reject ambiguous statement dates unless a bank/import profile establishes the date convention. + +### E. Razorpay +- Replace duplicated test/live implementations with one shared rail engine. +- Retain separate constructors, credentials, storage namespaces/tables and explicit mode checks to preserve blast-radius isolation. +- Express temporary pilot restrictions, such as fixed ₹1 live checkout, as policy/configuration rather than forked code. + +### F. API and operator console +- Split API registration into public checkout, trusted integration, evidence ingest, relay, operator and provider modules. +- Introduce typed operator query endpoints and stop exposing database collection schemas to the browser. +- Use polling or a small SSE invalidation stream for live operator updates. +- Build stable response DTOs that deliberately exclude raw evidence unless a privileged detail endpoint requires it. + +### G. Customer checkout +- Reduce the checkout to one primary action per state. +- Remove duplicated explanations, secondary controls and technical terminology from the main path. +- Keep payment account selection automatic by default; only show a chooser when multiple rails are ready and there is a user-relevant reason to choose. +- Design the pending screen around amount, QR/action, countdown and authoritative verification state. +### H. Android / PayGate mobile +- Keep the relay runtime engine but replace the raw Java settings/debug screen. +- Move the user-facing application to Kotlin + Jetpack Compose while allowing the Java relay engine to coexist during migration. +- Add authenticated operator sessions with device-bound secure token storage. +- Add home dashboard, payment search/list/detail, review queue, alert inbox, relay health and settings. +- Make dangerous actions require explicit confirmation and server-side authorization/audit. +- Keep relay pairing credentials distinct from operator login credentials. + +## Explicit non-goals + +- No direct bank-account automation beyond the trusted evidence sources already supported. +- No automatic ambiguous-payment resolution. +- No automatic refund execution unless a provider API and explicit policy are later approved. +- No replay of old exhausted webhooks merely because transport has recovered. +- No secret material in mobile diagnostics, operator DTOs, logs or public API responses. +- No breaking production migration without dual-read/dual-write or a tested backfill path. + +## Success criteria + +PayGate v2 is considered ready to replace v1 only when: +- all current payment invariant and edge-case tests pass against the v2 engine; +- shadow comparison shows equivalent outcomes for existing evidence sources; +- production can roll back to the v1 path without database restore; +- customer checkout has fewer primary decisions and fewer controls per state; +- operator web and Android both use the same typed operator API; +- no UI depends on PocketBase collection names or realtime record payloads; +- v2 operational alerts do not re-open/increment continuously for unchanged conditions; +- relay health, backups, outbox and reconciliation have deterministic recovery tests. \ No newline at end of file diff --git a/docs/v2/01_TARGET_ARCHITECTURE.md b/docs/v2/01_TARGET_ARCHITECTURE.md new file mode 100644 index 0000000..12cc25c --- /dev/null +++ b/docs/v2/01_TARGET_ARCHITECTURE.md @@ -0,0 +1,189 @@ +# PayGate v2 — Target Architecture + +## Architecture style + +PayGate remains a single deployable Go application backed by SQLite, with separate static/customer and operator/mobile clients. Internally it is a modular monolith with strict dependency direction and explicit transaction boundaries. + +```text +HTTP / relay / provider adapters + | + v + Application services + | + v + Typed domain + | + v + Unit of Work / repos + | + v + SQLite/PocketBase adapter +``` + +Only the bottom adapter layer may know about PocketBase `core.App`, `core.Record`, collection names, or database field strings. + +## Domain modules + +### Payments +Owns allocation, idempotency, state transitions, fingerprint quarantine and payment queries. It does not parse SMS/email/notifications and does not make network calls. + +### Evidence +Owns normalized evidence identity, validation and matching. Source adapters turn source-specific messages into `Evidence`; the matcher never receives raw SMS/email/notification payloads. + +### Reviews +Owns ambiguous/unmatched/manual-resolution workflow. Manual linking cannot bypass amount, account, evidence uniqueness, creation-time or quarantine invariants. +### Refunds +Owns refund reservation, idempotency, provider/reference uniqueness and explicit state transitions. Execution remains manual/provider-specific until a provider adapter is explicitly approved. + +### Reconciliation +Owns statement import, normalized statement entries, classification and discrepancy review. Parsing happens outside write transactions; persistence and classification happen in bounded batches. + +### Relay health +Stores observed device facts. A pure policy evaluates those facts into `healthy`, `degraded`, `blocked` and reason codes. Readiness code consumes policy output rather than reproducing boolean logic. + +### Operations +Owns alerts, durable delivery, backup status, retention and operational health. Alert events and alert conditions are separate concepts. + +## Core value types + +```go +type Money struct { Paise int64 } +type PaymentID string +type EvidenceID string +type AccountID string +type PaymentStatus uint8 +type MatchOutcome uint8 +``` + +String serialization belongs at HTTP/storage boundaries. The domain should prefer typed enums/value objects so invalid statuses and field-name typos cannot silently propagate. + +## Payment aggregate + +A payment contains requested/payable money, account, lifecycle timestamps, idempotency identity, external identity, and an optional applied evidence reference. Payer/evidence details may be materialized for operational convenience, but evidence remains a first-class record rather than an ad-hoc set of payment columns. +## Normalized evidence + +```go +type Evidence struct { + ID EvidenceID + Source EvidenceSource + Account AccountID + Amount Money + OccurredFrom time.Time + OccurredTo time.Time + Reference string + ReferenceKind ReferenceKind + PayerName string + UPIID string +} +``` + +Point-in-time evidence uses the same value for `OccurredFrom` and `OccurredTo`. Paytm minute-precision/notification evidence can use an interval. One matcher therefore handles all sources without a second notification-specific payment engine. + +## Match algorithm + +1. Validate source/account/amount/reference requirements. +2. Resolve duplicate evidence/reference assignment before candidate selection. +3. Clamp/validate occurrence interval against ingestion time according to source policy. +4. Query at most two candidate payments by account + exact payable amount + creation/time/quarantine constraints. +5. `0 candidates` -> unmatched/review depending on source policy. +6. `1 candidate` -> derive paid vs late from occurrence time and lifecycle timestamps. +7. `2 candidates` -> ambiguous; never choose by recency or heuristic. +8. Persist normalized evidence, payment mutation, audit entry and outbox event in one transaction. + +Manual review supplies a selected payment ID to the same invariant evaluator. It does not use a separate permissive matching implementation. +## Unit of Work + +Application services receive a `UnitOfWork` abstraction rather than `core.App`: + +```go +type UnitOfWork interface { + Payments() PaymentRepository + Evidence() EvidenceRepository + Reviews() ReviewRepository + Outbox() OutboxRepository + Audit() AuditRepository +} + +type Transactor interface { + Within(ctx context.Context, fn func(UnitOfWork) error) error +} +``` + +The initial implementation may wrap PocketBase transactions. A later plain-SQLite implementation can replace it without changing application/domain code. + +## Durable outbox/delivery + +Payment/refund events and operator notifications share claim, lease, retry and exhaustion mechanics. Destination-specific senders remain separate. + +```text +outbox item -> available -> claimed -> sending + -> delivered + -> retry scheduled + -> exhausted +``` + +The payment transaction inserts an outbox item only. Network delivery is always asynchronous and outside that transaction. +## Alert semantics + +Two APIs are required: + +```go +RecordEvent(kind, severity, dedupe, details) +EnsureCondition(kind, severity, dedupe, active, details) +``` + +`RecordEvent` increments occurrence count because a new event occurred. `EnsureCondition` updates last-seen/details while a condition remains active but does not increment each scan. A resolved condition may increment/reopen once when it becomes active again. + +## API surfaces + +### Public checkout +Minimal endpoints for account availability, creating a checkout payment and reading public payment status. No raw evidence or operator data. Strong body/rate limits. + +### Trusted integration +Server-to-server payment/refund endpoints authenticated independently from the public checkout surface. + +### Evidence ingest +Signed/secret source-specific routes that normalize and pass evidence to the evidence application service. + +### Relay +Device enrollment, signed events and heartbeat. Relay device credentials authorize relay operations only. + +### Operator +Authenticated typed queries/actions for dashboard, payments, reviews, refunds, reconciliation, alerts, relay, delivery, backups and settings. This is the only administrative API used by web/mobile clients. + +### Provider +Razorpay test/live webhooks and order operations implemented by a shared provider engine with mode-specific policy/credentials. +## Storage target + +Preferred long-term tables/entities: +- `payments` +- `evidence` and source/raw evidence records +- `review_cases` +- `refunds` +- `reconciliation_runs` / `reconciliation_entries` +- `relay_devices` / `relay_events` +- `outbox` +- `alerts` / optional alert history +- `audit_events` +- Razorpay orders/events with test/live isolation +- operator identities/sessions + +Do not collapse tables merely to reduce table count; consolidate duplicated behavior, not useful isolation. + +## Background work + +Jobs are bounded and idempotent: +- expire due payments in batches; +- claim/deliver outbox items; +- evaluate operational conditions; +- retention/redaction; +- backup verification and restore drill; +- reconciliation batches if an import is still processing. + +No ordinary `GET` should expire unrelated payments or create unrelated webhook rows as a side effect. + +## Google Messages exit strategy + +The libgm connector remains available during migration, but Android Google Messages notification observation is shadow-compared against it. The Android shadow path cannot mutate payments or open reviews; it records only parser status, amount and a SHA-256 hash of the bank reference in the existing relay event. Operator-facing QR pairing/refresh routes and UI are retired during this phase, while Google-account pairing and reauthentication remain available for libgm continuity. + +The manual removal-review gate is deliberately strict over a bounded 14-day default window: at least 100 complete libgm samples, at least 100 parseable Android samples, 100% Android bank-reference coverage, 100% exact amount+reference parity within the correlation window, and zero libgm-only complete events. Reaching the gate does not disable the connector automatically; it only permits a reviewed removal change. The removal requires measured parity, not assumption. \ No newline at end of file diff --git a/docs/v2/02_MIGRATION_AND_ROLLOUT.md b/docs/v2/02_MIGRATION_AND_ROLLOUT.md new file mode 100644 index 0000000..4e1c60c --- /dev/null +++ b/docs/v2/02_MIGRATION_AND_ROLLOUT.md @@ -0,0 +1,124 @@ +# PayGate v2 — Migration and Rollout Plan + +## Principle + +This is a strangler rewrite, not a big-bang replacement. Production remains on the current v1 path until each v2 slice has characterization tests, shadow comparison where possible, reversible schema changes, and a rollback path that does not require restoring the database. + +## Phase 0 — Stabilize v1 before extraction + +- Merge/fix relay power-health compatibility. +- Replace webhook exhaustion alert scanning with aggregate condition semantics. +- [done in rewrite branch] Remove unsupported Google Messages QR fallback from operator-facing HTTP/UI paths; retain Google-account pairing/reauth during parity measurement. +- Add characterization tests for payment allocation, cancellation, late evidence, stale evidence, duplicate RRN/reference, idempotency, refunds and reconciliation. +- Capture production-like anonymized fixture cases for every evidence source. + +Exit: v1 behavior is frozen by tests and known operational bugs are not being copied into v2. + +## Phase 1 — Typed domain foundation + +- Add typed money/payment/evidence/outcome types without changing persistence. +- Add repository/UoW interfaces and PocketBase-backed adapters. +- Move one read-only operator query through the repository layer first. +- Move payment record serialization/mapping into one adapter location. +- Prevent new direct `core.Record` access outside adapters with package/lint conventions. + +Exit: application services can execute through repositories while the live schema remains unchanged. +## Phase 2 — Evidence normalization in shadow mode + +- Add normalized `Evidence` and source adapter interfaces. +- Run Kotak SMS, Slice email and Paytm notification fixtures through both v1 and v2 match decision logic. +- Persist v2 normalized evidence in shadow-only storage or test fixtures first; do not mutate production payment state from v2 yet. +- For Google Messages, reuse relay/SMS event history for shadow comparison and store only non-raw Android parse metadata plus a hashed bank reference; require the documented strict parity gate before manual libgm removal review. +- Compare candidate IDs, outcome, paid/late classification and rejection reason. +- Require exact parity on all invariant cases before enabling v2 writes. + +Exit: v2 matcher produces the same safe decision as v1 for all known/fixture cases and property tests. + +## Phase 3 — Transactional v2 write path + +- Enable v2 payment/evidence mutation behind an environment/feature flag. +- Keep current schema initially and write through the repository adapter. +- Preserve existing outbox/audit rows so downstream integrations remain unchanged. +- Start with one evidence source, then expand source by source. +- Record comparison telemetry without raw sensitive evidence. + +Rollback: disable v2 matcher flag; v1 reads the same persisted payments/evidence columns. + +## Phase 4 — Operator API decoupling + +- Add typed `/api/operator/v2/*` query/action endpoints. +- Port dashboard, payments, reviews, refunds, reconciliation, alerts, relay, delivery, backups and settings. +- Web operator console and Android app consume these endpoints only. +- Replace PocketBase realtime record subscriptions with polling/SSE invalidations. +- Once no client reads PocketBase collections directly, collection schema can evolve independently. + +Exit: browser/mobile clients have no collection-name or PocketBase realtime dependency. +## Phase 5 — Customer checkout v2 + +- Ship the redesigned checkout against the existing compatible payment contract first. +- Measure create errors, abandonment between create/payment, completion latency and status-refresh errors. +- Add direct public checkout API only after equivalent limits/abuse controls are implemented in Go. +- If the Node/Hono BFF is removed, keep the old frontend deployment available for immediate DNS/route rollback. + +## Phase 6 — PayGate mobile + +- Release the modern app UI while preserving the existing relay package/application ID and signing certificate. +- Maintain existing relay pairing/device identity across upgrade. +- Introduce operator authentication independently from relay identity. +- Start mobile management read-only; add reviewed actions one by one with audit and confirmation. +- Do not allow mobile UI/background service lifecycle changes to stop the relay when the operator logs out. + +## Phase 7 — Consolidation + +- Shared Razorpay engine replaces duplicated test/live business code while retaining credential/storage isolation. +- Shared durable delivery engine replaces duplicated webhook/operator notification retry mechanics. +- Batched expiry/reconciliation replace oversized/global transaction behavior. +- Configuration is decomposed into validated sub-configs. +- Legacy source aliases/routes are removed after usage counters reach zero. + +## Phase 8 — Optional PocketBase removal + +Only consider this after operator clients and application services are fully decoupled. Implement typed SQLite repositories (prefer explicit SQL/sqlc-style generated access) and migration tooling. Keep schema compatibility or provide a proven one-way migration plus backup/restore rehearsal. +## Required release gates for every phase + +1. `gofmt`, unit/integration tests, `go vet`, staticcheck, govulncheck and relevant race tests pass. +2. Frontend/mobile contract tests cover old and new server compatibility where a rolling upgrade is possible. +3. Database migrations are additive until all old readers are removed. +4. A fresh verified backup exists before any production schema/data remediation. +5. Migration scripts have a test that runs from a realistic pre-migration database. +6. Payment/evidence duplicate constraints are checked before and after rollout. +7. No production data reset, automatic historical replay, or destructive backfill. +8. Health/readiness must degrade safely if a required verification source is unhealthy. +9. Metrics/logs used for shadow comparison must not contain raw payer evidence or secrets. +10. Every operator mutation creates an audit entry containing actor, action and target identity. + +## Cutover strategy + +Prefer percentage/source-scoped feature flags over host-level blue/green for the payment matcher because both implementations need to observe the same SQLite state. Public web UI can be blue/green independently. + +Suggested matcher progression: fixtures -> test DB -> shadow production decisions -> one source/write path -> all direct UPI sources -> operator/manual flows -> old path disabled -> old path deleted after soak. + +## Rollback triggers + +Immediate rollback for any of: +- candidate/outcome mismatch against established invariant fixtures; +- duplicate evidence/reference or duplicate active fingerprint allocation; +- unexpected increase in ambiguous/unmatched evidence; +- payment creation success but missing durable outbox/audit mutation; +- elevated SQLite busy/write-lock time attributable to v2; +- mobile/operator action bypassing authorization or audit; +- checkout regression that obscures exact amount, expiry or authoritative verification status. + +Deletion of old code occurs only after a separate stable soak period; rollback must remain possible before deletion. +## Production-copy acceptance harness + +Never point migration acceptance at the live `/app/pb_data` volume. Restore or copy a verified backup into an isolated directory first, then create an explicit marker only in that copy: + +```bash +touch /protected/paygate-v2-copy/.paygate-acceptance-copy +./scripts/v2-production-copy-acceptance.sh \ + --source-copy /protected/paygate-v2-copy \ + --binary /path/to/v2/paygate +``` + +The harness clones that supplied copy again into a temporary workspace, checks SQLite integrity, snapshots financial/evidence row counts and uniqueness violations, applies `migrate up` only to the temporary clone, rechecks integrity, verifies the v2 shadow schema/migration, and requires the invariant snapshot to remain unchanged. The temporary workspace is deleted automatically. diff --git a/docs/v2/03_CUTOVER_RUNBOOK.md b/docs/v2/03_CUTOVER_RUNBOOK.md new file mode 100644 index 0000000..d085180 --- /dev/null +++ b/docs/v2/03_CUTOVER_RUNBOOK.md @@ -0,0 +1,125 @@ +# PayGate v2 — Production Cutover Runbook + +## Scope + +This runbook covers the production transition for: +- API service `main-payment-17aqux`; +- customer frontend `main-payment-frontend-t1n1x8`; +- PayGate Android operator/relay app; +- domains `pay.mulearnscet.in`, `payment.mulearnscet.in`, and `pay.ieeesahrdaya.com`. + +The database is SQLite on the persistent Docker volume mounted at `/app/pb_data`. Never run two PayGate API tasks against that volume at once. + +## Preconditions + +All of the following must be true before merging/deploying: +- API PR CI is green, including race, vet, staticcheck, govulncheck, and container build. +- Customer frontend PR CI is green and static-container smoke tests have passed. +- Android PR CI is green and the debug APK builds successfully. +- The latest production backup checksum verifies. +- `backup-verify` passes on the current production binary. The host preflight independently extracts the latest archive and verifies the active root `data.db` and `auxiliary.db`; this is the pre-v2 restore-integrity gate because the old binary incorrectly scans nested forensic/quarantine `.db` snapshots. +- `scripts/v2-production-copy-acceptance.sh` passes against a fresh restored production backup. +- The production-copy alert test proves legacy webhook alerts aggregate without replay. +- Production health is green before cutover. +- `./scripts/v2-host-preflight.sh disabled` passes from the deployment host. The service image must be pinned; `:latest` is rejected. +- Dokploy `autoDeploy` is disabled for both the API and customer frontend before merging v2 PRs; promotion is manual from pinned release images during the initial soak. + +## Environment normalization + +The current production environment passes a non-printing v2 `Load()` + `ValidateServe()` dry-run unchanged. Do **not** normalize names before the first compatibility deployment; minimizing simultaneous changes is safer. After Phase A is healthy and before Phase B, normalize names in the deployment source of truth without printing or rotating existing values: +- copy `UPI_ID` to `KOTAK_UPI_ID`, then retire `UPI_ID`; +- copy `UPI_PAYEE_NAME` to `KOTAK_UPI_PAYEE_NAME`, then retire `UPI_PAYEE_NAME`; +- retain `PAYMENT_TTL` and retire the older `TICKET_TTL_MINUTES` fallback; +- because `LEGACY_SMS_WEBHOOK_ENABLED=false`, retire the unused `WEBHOOK_SECRET` after confirming `/api/webhook` remains 404; +- remove stale Appwrite variables, `COOKIE_SECRET`, `ONE_TIME_CODE`, `PUBLIC_BASE_URL`, and `RP_ID`; +- retain active PayGate API, SMS/email evidence, Android relay, outgoing webhook, Slice, Paytm, Google Messages, persistence, payment TTL/quarantine, and rate-limit settings; +- leave orchestrator-level `HOST`/`PORT` unchanged for the first cutover even though the PayGate binary does not consume them directly. + +A non-printing config dry-run must pass again after normalization. `PAYGATE_CHECKOUT_ORIGINS` stays unset in Phase A. In Phase B set it only to `https://payment.mulearnscet.in,https://pay.ieeesahrdaya.com`. + +## Phase A — API compatibility deployment + +1. Create a fresh production backup and verify its archive checksum. +2. Run the non-destructive restore drill. +3. Record the current API image/task identity and verify the persistent volume mount. Keep the preserved pre-v2 image tag for rollback and confirm Dokploy auto-deploy remains disabled. +4. Keep `PAYGATE_CHECKOUT_ORIGINS` unset/empty for the first API deployment. +5. Keep Android relay enrollment closed. +6. Build/tag the v2 API with an immutable release identifier (prefer the commit SHA, for example `main-payment-17aqux:v2-`), then deploy that exact image with one replica and stop-first semantics. Never deploy production from `:latest`. Set `PAYGATE_EXPECTED_IMAGE` to that exact image when running host preflight. +7. Allow schema migrations to complete against the existing persistent volume. +8. Verify `/api/health` and `/api/paygate/health` immediately. +9. Run the fixed v2 `backup-restore-drill` and require it to pass for the active root databases; nested forensic/quarantine snapshots must not be treated as restore targets. +10. Verify Google Messages remains paired/connected and the Android relay remains ready. +11. Verify trusted payment creation remains authenticated and public checkout remains disabled. +12. Verify retired Google Messages QR routes return 404 for an authenticated operator. Current v1 keeps these routes behind auth and returns 401 anonymously; v2 removes them entirely. +13. Verify operator login and `/api/operator/v2/overview`. +14. Wait through at least one operational-alert cron pass and confirm: + - exhausted webhook rows are unchanged; + - legacy per-delivery webhook alerts are resolved; + - exactly one aggregate `webhook:exhausted` alert is open while exhausted rows remain; + - no historical delivery is replayed. + +Rollback immediately if database health, payment reads, relay readiness, connector state, authentication, or outbox behavior regresses. + +## Phase B — Enable browser checkout + +After the API compatibility soak is clean: +1. Set `PAYGATE_CHECKOUT_ORIGINS` to the exact production customer origins only. +2. Redeploy/restart the API and run `./scripts/v2-host-preflight.sh enabled https://payment.mulearnscet.in` and again for `https://pay.ieeesahrdaya.com`; both must pass exact-origin CORS and denied-origin checks. +3. Confirm anonymous trusted `/api/payments` remains 401. +4. Confirm `/api/checkout/v2/payment-accounts` is reachable only from an allowed browser origin. +5. Confirm create/status quotas and `Retry-After` behavior. +6. Verify Kotak, Slice and Paytm account readiness before changing frontend traffic. + +## Phase C — Static customer frontend + +1. Retain the existing frontend image/task metadata for rollback. +2. Deploy the v2 static Nginx image from an immutable release tag/digest, not `:latest`. +3. Verify `/api/health` on the frontend container and SPA deep-link routing. +4. Verify CSP, HSTS, frame, referrer, content-type and asset-cache headers. +5. Verify both customer domains render the same static build. +6. Create a harmless checkout on each direct PayGate rail and cancel it if unpaid. +7. Run Razorpay Test end-to-end. +8. Keep Razorpay Live limited to the existing ₹1 pilot until separately approved. + +Rollback the frontend independently if the UI or public checkout regresses; the API compatibility routes remain available during the initial soak. + +## Phase D — Android app upgrade + +1. Merge/release Android only after the required v2 operator endpoints are live. +2. Build the signed release APK with the existing package name and signing identity. +3. Install over the existing app; do not uninstall and do not clear app data. +4. Verify the relay device identity/pairing survives the upgrade. +5. Verify the foreground relay service starts and remains active independently of operator login. +6. Verify notification-listener access and battery-optimization exemption remain granted. +7. Verify signed heartbeat, pending queue and failed queue return healthy. +8. Sign in as an operator and verify Home, Payments, Reviews and Health. +9. Verify operator token refresh does not interrupt the relay runtime. +10. Verify the Google Messages shadow gate is visible but cannot change payment state. + +## Real-payment acceptance + +Perform one controlled transaction per verification rail: +- Kotak SMS; +- Slice email; +- Paytm notification while the phone is locked and Battery Saver is enabled; +- Razorpay Test; +- Razorpay Live ₹1 pilot when explicitly approved. + +For each transaction verify exact amount, evidence identity, timestamps, one state transition, durable outbox behavior, no duplicate evidence, expected review behavior and audit history. Never use manual confirmation to rescue an automatic-match test. + +## Google Messages retirement + +Do not remove libgm during initial v2 cutover. Run the Android Google Messages path in observation-only shadow mode until the operator metric reaches the documented manual-review gate: at least 100 complete libgm samples, at least 100 parseable Android samples, 100% Android bank-reference coverage, 100% exact amount+reference parity, and zero libgm-only complete events. + +Reaching the gate only permits a reviewed removal change. Remove libgm/session/reauth code in a later PR after a stable soak and a fresh backup. + +## Rollback + +Application rollback is preferred over database restoration because v2 migrations are additive during this cutover. + +- API: use the Docker/Dokploy service rollback to the previous image, preserving `/app/pb_data`. +- Frontend: roll back independently to the previous frontend task/image. +- Android: retain the previous signed APK as an emergency downgrade artifact; do not clear app data. +- Database restore is last resort only. Scale the API to zero before replacing `/app/pb_data` and verify SQLite integrity before restart. + +Immediate rollback triggers include duplicate RRN/reference/fingerprint allocation, incorrect amount matching, unexpected payment confirmation, missing outbox/audit mutations, database integrity failure, sustained SQLite lock regression, relay readiness regression, or a customer checkout that obscures the authoritative amount/status. diff --git a/docs/v3/00_OPERATOR_PRODUCT_RECOVERY.md b/docs/v3/00_OPERATOR_PRODUCT_RECOVERY.md new file mode 100644 index 0000000..e8f5564 --- /dev/null +++ b/docs/v3/00_OPERATOR_PRODUCT_RECOVERY.md @@ -0,0 +1,75 @@ +# PayGate v3 Operator Product Recovery + +## Purpose + +This document defines the recovered v3 operator-console scope built from the exact v2 API base `a89845b7`. It is intentionally narrower than a generic finance back office: common payment work is simple, while evidence, reconciliation, refunds and infrastructure remain available without dominating the primary UI. + +The recovery branch is not a production cutover branch. Before any merge or deployment, compare it with the stranded/original v3 worktree when that environment is available and resolve differences explicitly. + +## Product hierarchy + +The primary operator navigation is: + +1. **Overview** — current payment state and only the attention that matters now. +2. **Payments** — search, create, inspect and manage every payment. +3. **Action** — fail-closed cases that require a human decision. +4. **Health** — verification rails, recovery readiness and open operational alerts. +5. **More** — a grouped advanced-tools hub for investigation, recovery and infrastructure work. + +Reconciliation, refunds, raw SMS/email evidence, Razorpay test mode, alert history, webhook delivery diagnostics, audit history and low-frequency settings live behind More instead of crowding daily navigation. The More route remains available on mobile, so advanced tools are not accidentally desktop-only. + +## Payment management contract + +Operators can search across payment ID, external/order ID, display name, customer details, captured payer name, RRN/UTR, UPI ID, evidence reference, description and private admin note. Search values are bound parameters in a fixed PocketBase filter expression; user input is never concatenated into the filter grammar. +The payment list supports validated status/account filters, a sort whitelist, total counts and bounded pagination. Summary rows expose business context but not sensitive matching evidence; evidence is shown only in the authenticated payment detail surface. + +Editable operator-owned fields are limited to: + +- display name +- customer name, email and phone +- description +- private admin note +- tags +- custom fields + +Every effective profile change is saved and audited in the same database transaction. The audit record stores the changed-field list plus before/after snapshots of the editable profile. Custom JSON is represented by a digest rather than duplicated into audit storage, and protected creation/evidence fields are never copied into this profile audit. Saving an unchanged form is a no-op and does not create audit noise. + +## Immutable payment truth + +The operator profile endpoint must never mutate fields that participate in payment matching, uniqueness, lifecycle truth or create-request identity. The following remain read-only: + +- payment account +- requested and payable amount +- payment status and lifecycle timestamps +- reuse/quarantine window +- RRN/UTR, payer identity and evidence source/reference +- idempotency key +- external/order ID +- original create metadata + +`externalId` and original `metadata` are specifically protected because create idempotency replay compares them with the original request. Editing either after creation could turn a legitimate retry into an idempotency conflict. +The HTTP update boundary uses a strict JSON decoder. Unknown keys — including attempts to submit protected fields — are rejected with HTTP 400 instead of being silently discarded. + +## Storage and migration + +The v3 migration is additive. It adds only operator-owned profile fields and search indexes to `payments`; existing payment/evidence fields and indexes are not rewritten. Rollback removes only those v3 additions. + +The SQLite single-writer invariant remains unchanged. Do not run a second API instance against the same persistent data directory during testing, rollout or restore operations. + +## Acceptance gates + +A recovery commit is acceptable only when all of the following pass: + +- `npm test` +- `go test ./...` +- `go vet ./...` +- `git diff --check` +- adversarial API tests for authentication and protected-field rejection +- desktop and 390 px mobile browser QA with no horizontal overflow or runtime errors +- secret/temp-artifact review of the source diff + +Production rollout remains separate from source acceptance. Use a pinned immutable image, preserve one writer, keep automatic deployment disabled during manual cutover, and retain all v2 matching/evidence fail-closed invariants. + +## Recovery reconciliation + +The original Oracle v3 worktree was recovered and compared file-by-file from the same `a89845b7` base. The detailed disposition is recorded in `01_ORACLE_RECONCILIATION.md`. Prefer tested invariant-preserving behavior over recovered code where the two disagree. diff --git a/docs/v3/01_ORACLE_RECONCILIATION.md b/docs/v3/01_ORACLE_RECONCILIATION.md new file mode 100644 index 0000000..ba02932 --- /dev/null +++ b/docs/v3/01_ORACLE_RECONCILIATION.md @@ -0,0 +1,44 @@ +# PayGate v3 — Oracle Worktree Reconciliation + +## Compared states + +Both implementations start from the same v2 foundation commit: `a89845b7`. + +- Original Oracle worktree: `redesign/paygate-product-v3`, uncommitted. +- Recovery branch: `redesign/paygate-product-v3-recovery`. +- Comparison performed after the Oracle host returned online on 2026-08-30. + +The Oracle worktree contained 14 modified tracked files and six untracked v3 files. It was treated as recovered design/code input, not as automatically authoritative source. + +## Carried forward from Oracle + +The following original ideas improved the recovery branch without weakening payment invariants: + +- A dedicated **More** hub groups advanced money operations, evidence/delivery tools and system controls. +- More remains in primary navigation so mobile operators can reach advanced tools; advanced routes no longer disappear with the desktop sidebar. +- Payment search includes captured payer name, description and private admin note in addition to IDs, customer fields, UPI and evidence references. +- Status is available as an additional whitelisted payment sort. +- Protected IDs, references, UPI values and key amounts expose convenient copy actions. +- Effective payment-profile edits record before/after audit snapshots, while custom JSON is represented by a digest. + +## Intentionally not carried forward + +The recovery branch remains stricter where the Oracle draft conflicted with create/idempotency or validation guarantees: + +- `externalId` is not editable after creation. Idempotent replay compares it with the original create request. +- Original create `metadata` is not editable after creation for the same replay-identity reason. +- The update API uses strict JSON decoding; unknown/protected keys fail with HTTP 400 instead of being ignored. +- Invalid pagination/filter input is rejected rather than silently clamped into another query. +- Oversized tags are rejected rather than silently dropped. +- Operator custom fields use bounded storage/request sizes instead of the broader draft limits. +- The recovery UI and Health implementation were retained where they had already passed desktop/mobile browser QA and the Oracle draft did not provide a stronger invariant or capability. + +## Audit disposition + +The Oracle draft's richer audit concept was retained but narrowed to safe editable profile data. Audit snapshots include display/customer fields, description, private note and tags. `customFields` is represented by a SHA-256 digest. Protected creation identity, financial truth and captured evidence are not duplicated into the profile-edit audit payload. + +An unchanged form remains a no-op and must not emit an audit event. + +## Scope boundary + +This reconciliation closes the **web operator/admin v3** recovery gap. The Oracle product plan also described later customer-checkout and Android-operator redesign stages. Those are separate surfaces and should be implemented/reconciled independently after this admin PR is accepted; they must not be smuggled into the production API cutover without their own tests and rollout gates. diff --git a/internal/alerts/service.go b/internal/alerts/service.go index 9dc7acd..c0e8176 100644 --- a/internal/alerts/service.go +++ b/internal/alerts/service.go @@ -14,13 +14,13 @@ import ( "sync" "time" + "github.com/Phloraxx/payment-api/internal/deliveryqueue" "github.com/Phloraxx/payment-api/internal/gmessages" "github.com/Phloraxx/payment-api/internal/payments" "github.com/Phloraxx/payment-api/internal/webhooks" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/security" - "github.com/pocketbase/pocketbase/tools/types" ) const maxNotificationAttempts = 6 @@ -64,7 +64,34 @@ func (s *Service) NotificationsEnabled() bool { return s != nil && s.WebhookURL != "" && s.WebhookSecret != "" } +func (s *Service) notificationQueue() deliveryqueue.Queue { + return deliveryqueue.Queue{ + App: s.App, Collection: "alerts", MaxAttempts: maxNotificationAttempts, + Fields: deliveryqueue.Fields{ + Status: "notification_status", Attempts: "notification_attempts", + NextAttemptAt: "notification_next_attempt_at", LockedAt: "notification_locked_at", + LastAttemptAt: "notification_last_attempt_at", DeliveredAt: "notification_delivered_at", + LastError: "notification_last_error", + }, + RetryDelays: []time.Duration{time.Minute, 5 * time.Minute, 30 * time.Minute, 2 * time.Hour, 6 * time.Hour}, + StaleAfter: 2 * time.Minute, ExhaustedAfter: 365 * 24 * time.Hour, ErrorMax: 4096, + StaleMessage: "recovered stale operator alert delivery lease after restart", + } +} + func (s *Service) Open(input Input) (string, bool, error) { + return s.upsert(input, true) +} + +// EnsureOpen records a persistent operational condition without counting every +// periodic health scan as a new occurrence. A resolved condition increments +// once when it becomes active again; an already-open condition only refreshes +// its details/last-seen state. +func (s *Service) EnsureOpen(input Input) (string, bool, error) { + return s.upsert(input, false) +} + +func (s *Service) upsert(input Input, countRepeated bool) (string, bool, error) { input.Kind = strings.TrimSpace(input.Kind) input.Severity = strings.TrimSpace(input.Severity) input.DedupeKey = strings.TrimSpace(input.DedupeKey) @@ -88,7 +115,11 @@ func (s *Service) Open(input Input) (string, bool, error) { record.Set("details", input.Details) record.Set("last_seen_at", now) record.Set("resolved_at", "") - record.Set("occurrence_count", record.GetInt("occurrence_count")+1) + count := record.GetInt("occurrence_count") + if countRepeated || wasResolved { + count++ + } + record.Set("occurrence_count", count) if s.NotificationsEnabled() { if wasResolved || severityEscalated || record.GetString("notification_status") == "disabled" { s.queueNotification(record, now) @@ -212,15 +243,11 @@ func (s *Service) SendPending(ctx context.Context) (int, error) { return 0, nil } now := s.now() - if err := s.recoverStaleNotifications(now); err != nil { + queue := s.notificationQueue() + if err := queue.RecoverStale(now, 50); err != nil { return 0, err } - records, err := s.App.FindRecordsByFilter( - "alerts", - "(notification_status = 'pending' || notification_status = 'failed') && notification_next_attempt_at <= {:now}", - "notification_next_attempt_at,created", 50, 0, - dbx.Params{"now": filterDate(now)}, - ) + records, err := queue.Due(now, 50) if err != nil { return 0, err } @@ -229,7 +256,7 @@ func (s *Service) SendPending(ctx context.Context) (int, error) { if err := ctx.Err(); err != nil { return processed, err } - claimed, err := s.claimNotification(record.Id, now) + claimed, err := queue.Claim(record.Id, now) if err != nil { s.logger().Warn("failed to claim operator alert delivery", "alertId", record.Id, "error", err) continue @@ -255,33 +282,6 @@ func (s *Service) queueNotification(record *core.Record, now time.Time) { record.Set("notification_last_error", "") } -func (s *Service) claimNotification(id string, now time.Time) (*core.Record, error) { - var claimed *core.Record - err := s.App.RunInTransaction(func(tx core.App) error { - record, err := tx.FindRecordById("alerts", id) - if err != nil { - return err - } - status := record.GetString("notification_status") - if status != "pending" && status != "failed" { - return nil - } - if next := record.GetDateTime("notification_next_attempt_at").Time(); !next.IsZero() && next.After(now) { - return nil - } - record.Set("notification_status", "sending") - record.Set("notification_locked_at", now) - record.Set("notification_last_attempt_at", now) - record.Set("notification_attempts", record.GetInt("notification_attempts")+1) - if err := tx.Save(record); err != nil { - return err - } - claimed = record.Clone() - return nil - }) - return claimed, err -} - func (s *Service) deliverNotification(ctx context.Context, record *core.Record) { eventID := record.GetString("notification_event_id") eventType := "operational.alert.opened" @@ -329,73 +329,18 @@ func (s *Service) deliverNotification(ctx context.Context, record *core.Record) } func (s *Service) finishNotification(id, eventID string, statusCode int, deliveryErr error) error { - now := s.now() - return s.App.RunInTransaction(func(tx core.App) error { - record, err := tx.FindRecordById("alerts", id) - if err != nil { - return err - } - if record.GetString("notification_event_id") != eventID || record.GetString("notification_status") != "sending" { - return nil // a newer open/resolved notification superseded this attempt - } - record.Set("notification_locked_at", "") - if deliveryErr == nil { - record.Set("notification_status", "delivered") - record.Set("notification_delivered_at", now) - record.Set("notification_last_error", "") - return tx.Save(record) - } - attempts := record.GetInt("notification_attempts") - record.Set("notification_last_error", truncate(deliveryErr.Error(), 4096)) - if attempts >= maxNotificationAttempts { - record.Set("notification_status", "exhausted") - record.Set("notification_next_attempt_at", now.Add(365*24*time.Hour)) - } else { - record.Set("notification_status", "failed") - record.Set("notification_next_attempt_at", now.Add(notificationRetryDelay(attempts))) - } - return tx.Save(record) + return s.notificationQueue().Finish(id, s.now(), statusCode, deliveryErr, func(record *core.Record) bool { + return record.GetString("notification_event_id") == eventID && + record.GetString("notification_status") == "sending" }) } -func (s *Service) recoverStaleNotifications(now time.Time) error { - records, err := s.App.FindRecordsByFilter( - "alerts", "notification_status = 'sending' && notification_locked_at < {:stale}", - "notification_locked_at", 50, 0, dbx.Params{"stale": filterDate(now.Add(-2 * time.Minute))}, - ) - if err != nil { - return err - } - for _, record := range records { - record.Set("notification_status", "failed") - record.Set("notification_locked_at", "") - record.Set("notification_next_attempt_at", now) - record.Set("notification_last_error", "recovered stale operator alert delivery lease after restart") - if err := s.App.Save(record); err != nil { - return err - } - } - return nil -} - -func notificationRetryDelay(attempt int) time.Duration { - delays := []time.Duration{time.Minute, 5 * time.Minute, 30 * time.Minute, 2 * time.Hour, 6 * time.Hour} - index := attempt - 1 - if index < 0 { - index = 0 - } - if index >= len(delays) { - return delays[len(delays)-1] - } - return delays[index] -} - func (s *Service) CheckConnector(status gmessages.Status) error { if !status.Enabled { return s.resolveProblems("connector:reauth", "connector:disconnected", "connector:unresponsive") } if status.State == "reauth_required" { - if _, _, err := s.Open(Input{Kind: "connector_reauth", Severity: "critical", DedupeKey: "connector:reauth", Message: "Google Messages requires browser reauthentication", Details: map[string]any{"state": status.State, "lastError": status.LastError}}); err != nil { + if _, _, err := s.EnsureOpen(Input{Kind: "connector_reauth", Severity: "critical", DedupeKey: "connector:reauth", Message: "Google Messages requires browser reauthentication", Details: map[string]any{"state": status.State, "lastError": status.LastError}}); err != nil { return err } } else if err := s.Resolve("connector:reauth"); err != nil { @@ -407,7 +352,7 @@ func (s *Service) CheckConnector(status gmessages.Status) error { return err } } else if status.Paired && s.problemMature("connector:disconnected", 5*time.Minute) { - if _, _, err := s.Open(Input{Kind: "connector_disconnected", Severity: "critical", DedupeKey: "connector:disconnected", Message: "Google Messages has remained disconnected for more than five minutes", Details: map[string]any{"state": status.State, "lastError": status.LastError}}); err != nil { + if _, _, err := s.EnsureOpen(Input{Kind: "connector_disconnected", Severity: "critical", DedupeKey: "connector:disconnected", Message: "Google Messages has remained disconnected for more than five minutes", Details: map[string]any{"state": status.State, "lastError": status.LastError}}); err != nil { return err } } @@ -417,7 +362,7 @@ func (s *Service) CheckConnector(status gmessages.Status) error { return err } } else if s.problemMature("connector:unresponsive", 15*time.Minute) { - if _, _, err := s.Open(Input{Kind: "connector_unresponsive", Severity: "warning", DedupeKey: "connector:unresponsive", Message: "Paired phone has not been responsive for more than fifteen minutes", Details: map[string]any{"state": status.State}}); err != nil { + if _, _, err := s.EnsureOpen(Input{Kind: "connector_unresponsive", Severity: "warning", DedupeKey: "connector:unresponsive", Message: "Paired phone has not been responsive for more than fifteen minutes", Details: map[string]any{"state": status.State}}); err != nil { return err } } @@ -437,7 +382,7 @@ func (s *Service) CheckCapacity(snapshot payments.CapacitySnapshot) error { severity = "critical" } message := fmt.Sprintf("₹%s fingerprint pool is %.0f%% utilized (%d of 99 blocked)", pool.RequestedAmount, pool.UtilizationPercent, pool.Blocked) - if _, _, err := s.Open(Input{Kind: "capacity_high", Severity: severity, DedupeKey: key, Message: message, Details: pool}); err != nil { + if _, _, err := s.EnsureOpen(Input{Kind: "capacity_high", Severity: severity, DedupeKey: key, Message: message, Details: pool}); err != nil { return err } } @@ -456,30 +401,80 @@ func (s *Service) CheckCapacity(snapshot payments.CapacitySnapshot) error { } func (s *Service) CheckWebhookExhaustion() error { - records, err := s.App.FindRecordsByFilter("webhook_deliveries", "status = 'exhausted'", "-updated", 100, 0) + count, err := s.App.CountRecords("webhook_deliveries", dbx.NewExp("status = 'exhausted'")) if err != nil { return err } - active := map[string]struct{}{} - for _, record := range records { - key := "webhook:" + record.Id - active[key] = struct{}{} - if _, _, err := s.Open(Input{Kind: "webhook_exhausted", Severity: "critical", DedupeKey: key, Message: "Outgoing webhook exhausted its retry limit", Details: map[string]any{"deliveryId": record.Id, "eventId": record.GetString("event_id"), "event": record.GetString("event"), "lastError": record.GetString("last_error")}}); err != nil { - return err + if err := s.resolveLegacyWebhookAlerts(); err != nil { + return err + } + const dedupeKey = "webhook:exhausted" + if count == 0 { + return s.Resolve(dedupeKey) + } + + exhausted, err := s.App.FindRecordsByFilter("webhook_deliveries", "status = 'exhausted'", "-updated", 1, 0) + if err != nil { + return err + } + details := map[string]any{"exhaustedCount": count} + message := fmt.Sprintf("%d outgoing webhook deliveries have exhausted their retry limit", count) + if len(exhausted) == 1 { + latest := exhausted[0] + details["latestExhaustedAt"] = latest.GetDateTime("updated").String() + details["latestEvent"] = latest.GetString("event") + latestExhaustedAt := latest.GetDateTime("updated").Time() + delivered, deliveredErr := s.App.FindRecordsByFilter("webhook_deliveries", "status = 'delivered' && delivered_at != ''", "-delivered_at", 1, 0) + if deliveredErr != nil { + return deliveredErr + } + if len(delivered) == 1 { + latestDeliveredAt := delivered[0].GetDateTime("delivered_at").Time() + details["latestDeliveredAt"] = delivered[0].GetDateTime("delivered_at").String() + if !latestDeliveredAt.IsZero() && latestDeliveredAt.After(latestExhaustedAt) { + details["transportRecovered"] = true + message += "; newer webhook deliveries have succeeded" + } } } - open, err := s.App.FindRecordsByFilter("alerts", "kind = 'webhook_exhausted' && status = 'open'", "created", 0, 0) + _, _, err = s.EnsureOpen(Input{ + Kind: "webhook_exhausted", Severity: "critical", DedupeKey: dedupeKey, + Message: message, Details: details, + }) + return err +} + +// resolveLegacyWebhookAlerts silently closes the per-delivery alerts generated +// by the pre-v2 scanner. They represent the same aggregate condition and must +// not emit hundreds of "resolved" notifications during remediation. +func (s *Service) resolveLegacyWebhookAlerts() error { + records, err := s.App.FindRecordsByFilter( + "alerts", + "kind = 'webhook_exhausted' && dedupe_key != 'webhook:exhausted' && status = 'open'", + "created", 0, 0, + ) if err != nil { return err } - for _, record := range open { - if _, ok := active[record.GetString("dedupe_key")]; !ok { - if err := s.Resolve(record.GetString("dedupe_key")); err != nil { + if len(records) == 0 { + return nil + } + now := s.now() + return s.App.RunInTransaction(func(tx core.App) error { + for _, item := range records { + record, err := tx.FindRecordById("alerts", item.Id) + if err != nil { + return err + } + record.Set("status", "resolved") + record.Set("resolved_at", now) + record.Set("notification_status", "disabled") + if err := tx.Save(record); err != nil { return err } } - } - return nil + return nil + }) } func (s *Service) problemMature(key string, delay time.Duration) bool { @@ -537,10 +532,3 @@ func truncate(value string, max int) string { } return value[:max] } -func filterDate(t time.Time) string { - value, err := types.ParseDateTime(t.UTC()) - if err != nil { - return t.UTC().Format(time.RFC3339Nano) - } - return value.String() -} diff --git a/internal/alerts/service_test.go b/internal/alerts/service_test.go index 9abeb3f..4f42a60 100644 --- a/internal/alerts/service_test.go +++ b/internal/alerts/service_test.go @@ -2,16 +2,19 @@ package alerts import ( "context" + "fmt" "io" "net/http" "net/http/httptest" "testing" "time" + "github.com/Phloraxx/payment-api/internal/config" "github.com/Phloraxx/payment-api/internal/gmessages" "github.com/Phloraxx/payment-api/internal/payments" "github.com/Phloraxx/payment-api/internal/webhooks" _ "github.com/Phloraxx/payment-api/migrations" + "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tests" ) @@ -227,3 +230,124 @@ func TestOperatorAlertClientDoesNotFollowRedirects(t *testing.T) { t.Fatalf("notification status=%s", record.GetString("notification_status")) } } + +func TestEnsureOpenDoesNotInflatePersistentCondition(t *testing.T) { + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + defer app.Cleanup() + service := NewService(app) + now := time.Date(2026, 8, 1, 8, 0, 0, 0, time.UTC) + service.Now = func() time.Time { return now } + + id, created, err := service.EnsureOpen(Input{Kind: "relay_unavailable", Severity: "warning", DedupeKey: "relay:paytm", Message: "relay unavailable", Details: map[string]any{"ready": false}}) + if err != nil || !created { + t.Fatalf("id=%s created=%v err=%v", id, created, err) + } + now = now.Add(time.Minute) + if second, created, err := service.EnsureOpen(Input{Kind: "relay_unavailable", Severity: "warning", DedupeKey: "relay:paytm", Message: "still unavailable", Details: map[string]any{"ready": false}}); err != nil || created || second != id { + t.Fatalf("second=%s created=%v err=%v", second, created, err) + } + record, _ := app.FindRecordById("alerts", id) + if got := record.GetInt("occurrence_count"); got != 1 { + t.Fatalf("occurrences while continuously open=%d", got) + } + if err := service.Resolve("relay:paytm"); err != nil { + t.Fatal(err) + } + now = now.Add(time.Minute) + if _, _, err := service.EnsureOpen(Input{Kind: "relay_unavailable", Severity: "warning", DedupeKey: "relay:paytm", Message: "unavailable again"}); err != nil { + t.Fatal(err) + } + record, _ = app.FindRecordById("alerts", id) + if got := record.GetInt("occurrence_count"); got != 2 { + t.Fatalf("occurrences after real reactivation=%d", got) + } +} + +func TestWebhookExhaustionIsOneAggregateCondition(t *testing.T) { + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + defer app.Cleanup() + service := NewService(app) + now := time.Date(2026, 8, 1, 8, 0, 0, 0, time.UTC) + service.Now = func() time.Time { return now } + + paymentService := payments.NewService(app, config.Config{PaymentTTL: 5 * time.Minute, AmountQuarantine: 24 * time.Hour, UPIID: "operator@bank", UPIPayeeName: "PayGate"}, nil) + paymentService.Now = func() time.Time { return now } + paymentService.SuffixStart = func() (int64, error) { return 1, nil } + payment, _, err := paymentService.Create(payments.CreateInput{AmountRupees: 100, PaymentAccount: "kotak"}) + if err != nil { + t.Fatal(err) + } + + collection, err := app.FindCollectionByNameOrId("webhook_deliveries") + if err != nil { + t.Fatal(err) + } + var deliveries []*core.Record + for i := 0; i < 2; i++ { + record := core.NewRecord(collection) + record.Set("event_id", fmt.Sprintf("evt_exhausted_%d", i)) + record.Set("event", "payment.paid") + record.Set("payment", payment.ID) + record.Set("url", "https://example.invalid/webhook") + record.Set("body", `{}`) + record.Set("attempts", 8) + record.Set("status", "exhausted") + record.Set("next_attempt_at", now.Add(24*time.Hour)) + record.Set("last_error", "historical failure") + if err := app.Save(record); err != nil { + t.Fatal(err) + } + deliveries = append(deliveries, record) + if _, _, err := service.Open(Input{Kind: "webhook_exhausted", Severity: "critical", DedupeKey: "webhook:" + record.Id, Message: "legacy per-delivery alert"}); err != nil { + t.Fatal(err) + } + } + + if err := service.CheckWebhookExhaustion(); err != nil { + t.Fatal(err) + } + aggregate, err := app.FindFirstRecordByData("alerts", "dedupe_key", "webhook:exhausted") + if err != nil { + t.Fatal(err) + } + if got := aggregate.GetInt("occurrence_count"); got != 1 { + t.Fatalf("aggregate occurrences=%d", got) + } + for _, delivery := range deliveries { + legacy, err := app.FindFirstRecordByData("alerts", "dedupe_key", "webhook:"+delivery.Id) + if err != nil { + t.Fatal(err) + } + if legacy.GetString("status") != "resolved" || legacy.GetString("notification_status") != "disabled" { + t.Fatalf("legacy alert status=%s notification=%s", legacy.GetString("status"), legacy.GetString("notification_status")) + } + } + if err := service.CheckWebhookExhaustion(); err != nil { + t.Fatal(err) + } + aggregate, _ = app.FindRecordById("alerts", aggregate.Id) + if got := aggregate.GetInt("occurrence_count"); got != 1 { + t.Fatalf("aggregate occurrences after repeat scan=%d", got) + } + + for _, delivery := range deliveries { + delivery.Set("status", "delivered") + delivery.Set("delivered_at", now.Add(time.Hour)) + if err := app.Save(delivery); err != nil { + t.Fatal(err) + } + } + if err := service.CheckWebhookExhaustion(); err != nil { + t.Fatal(err) + } + aggregate, _ = app.FindRecordById("alerts", aggregate.Id) + if aggregate.GetString("status") != "resolved" { + t.Fatalf("aggregate status after recovery=%s", aggregate.GetString("status")) + } +} diff --git a/internal/androidrelay/service.go b/internal/androidrelay/service.go index 51a9162..9a6d66f 100644 --- a/internal/androidrelay/service.go +++ b/internal/androidrelay/service.go @@ -1,6 +1,7 @@ package androidrelay import ( + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/sha256" @@ -17,8 +18,9 @@ import ( "unicode/utf8" "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/evidenceshadow" "github.com/Phloraxx/payment-api/internal/paytmnotification" - "github.com/pocketbase/dbx" + "github.com/Phloraxx/payment-api/internal/store" "github.com/pocketbase/pocketbase/core" ) @@ -38,13 +40,13 @@ var allowedPackages = map[string]bool{ } type Service struct { - App core.App + Store store.Database Paytm *paytmnotification.Service Now func() time.Time } func NewService(app core.App, paytm *paytmnotification.Service) *Service { - return &Service{App: app, Paytm: paytm, Now: time.Now} + return &Service{Store: store.NewPocketBase(app), Paytm: paytm, Now: time.Now} } type EnrollmentInput struct { @@ -112,51 +114,42 @@ func (s *Service) Enroll(in EnrollmentInput) (EnrollmentResult, error) { if in.Name == "" || utf8.RuneCountInString(in.Name) > 120 { return EnrollmentResult{}, domain.New("INVALID_RELAY_DEVICE_NAME", "device name is required and must be at most 120 characters", 400) } - pub, der, err := parsePublicKey(in.PublicKeyPEM) + _, der, err := parsePublicKey(in.PublicKeyPEM) if err != nil { return EnrollmentResult{}, domain.New("INVALID_RELAY_PUBLIC_KEY", "public key must be a P-256 ECDSA SubjectPublicKeyInfo PEM", 400) } - _ = pub sum := sha256.Sum256(der) if hex.EncodeToString(sum[:]) != in.DeviceID { return EnrollmentResult{}, domain.New("RELAY_DEVICE_ID_MISMATCH", "deviceId does not match public key fingerprint", 400) } now := s.now() - var enabled bool - err = s.App.RunInTransaction(func(tx core.App) error { - existing, findErr := tx.FindFirstRecordByFilter("relay_devices", "device_id = {:id}", dbx.Params{"id": in.DeviceID}) + enabled := false + err = s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + repo := uow.Relay() + existing, findErr := repo.FindByDeviceID(in.DeviceID) if findErr == nil { - if strings.TrimSpace(existing.GetString("public_key_pem")) != in.PublicKeyPEM { + if strings.TrimSpace(existing.PublicKeyPEM) != in.PublicKeyPEM { return domain.New("RELAY_DEVICE_KEY_CONFLICT", "this deviceId is already enrolled with a different public key", 409) } - existing.Set("name", in.Name) - existing.Set("app_version", trimMax(in.AppVersion, 64)) - existing.Set("android_version", trimMax(in.AndroidVersion, 64)) - existing.Set("device_model", trimMax(in.DeviceModel, 255)) - if existing.GetDateTime("enrolled_at").Time().IsZero() { - existing.Set("enrolled_at", now) + existing.Name = in.Name + existing.AppVersion = trimMax(in.AppVersion, 64) + existing.AndroidVersion = trimMax(in.AndroidVersion, 64) + existing.DeviceModel = trimMax(in.DeviceModel, 255) + if existing.EnrolledAt.IsZero() { + existing.EnrolledAt = now } - enabled = existing.GetBool("enabled") - return tx.Save(existing) + enabled = existing.Enabled + return repo.Save(existing) } if !errors.Is(findErr, sql.ErrNoRows) { return findErr } - c, err := tx.FindCollectionByNameOrId("relay_devices") - if err != nil { + device := &domain.RelayDevice{DeviceID: in.DeviceID, Name: in.Name, PublicKeyPEM: in.PublicKeyPEM, Enabled: true, AppVersion: trimMax(in.AppVersion, 64), AndroidVersion: trimMax(in.AndroidVersion, 64), DeviceModel: trimMax(in.DeviceModel, 255), EnrolledAt: now} + if err := repo.Create(device); err != nil { return err } - r := core.NewRecord(c) - r.Set("device_id", in.DeviceID) - r.Set("name", in.Name) - r.Set("public_key_pem", in.PublicKeyPEM) - r.Set("enabled", true) - r.Set("app_version", trimMax(in.AppVersion, 64)) - r.Set("android_version", trimMax(in.AndroidVersion, 64)) - r.Set("device_model", trimMax(in.DeviceModel, 255)) - r.Set("enrolled_at", now) enabled = true - return tx.Save(r) + return nil }) if err != nil { return EnrollmentResult{}, err @@ -164,7 +157,7 @@ func (s *Service) Enroll(in EnrollmentInput) (EnrollmentResult, error) { return EnrollmentResult{DeviceID: in.DeviceID, Enrolled: true, Enabled: enabled}, nil } -func (s *Service) Verify(deviceID, timestamp, signature, method, path string, body []byte) (*core.Record, error) { +func (s *Service) Verify(deviceID, timestamp, signature, method, path string, body []byte) (*domain.RelayDevice, error) { deviceID = strings.ToLower(strings.TrimSpace(deviceID)) tsMs, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64) if err != nil || tsMs <= 0 { @@ -179,11 +172,16 @@ func (s *Service) Verify(deviceID, timestamp, signature, method, path string, bo if delta > signatureTolerance { return nil, domain.New("STALE_RELAY_REQUEST", "relay request timestamp is outside the allowed window", 401) } - device, err := s.App.FindFirstRecordByFilter("relay_devices", "device_id = {:id}", dbx.Params{"id": deviceID}) - if err != nil || !device.GetBool("enabled") { + var device *domain.RelayDevice + err = s.Store.View(context.Background(), func(uow store.UnitOfWork) error { + var findErr error + device, findErr = uow.Relay().FindByDeviceID(deviceID) + return findErr + }) + if err != nil || device == nil || !device.Enabled { return nil, domain.New("UNKNOWN_RELAY_DEVICE", "relay device is not enrolled or is disabled", 401) } - pub, _, err := parsePublicKey(device.GetString("public_key_pem")) + pub, _, err := parsePublicKey(device.PublicKeyPEM) if err != nil { return nil, domain.New("INVALID_RELAY_DEVICE_KEY", "stored relay device key is invalid", 500) } @@ -200,7 +198,10 @@ func (s *Service) Verify(deviceID, timestamp, signature, method, path string, bo return device, nil } -func (s *Service) Ingest(device *core.Record, in EventInput, raw any) (EventResult, error) { +func (s *Service) Ingest(device *domain.RelayDevice, in EventInput, raw any) (EventResult, error) { + if device == nil { + return EventResult{}, domain.New("UNKNOWN_RELAY_DEVICE", "relay device is not enrolled or is disabled", 401) + } in.EventID = strings.ToLower(strings.TrimSpace(in.EventID)) in.Kind = strings.ToLower(strings.TrimSpace(in.Kind)) n := &in.Notification @@ -227,20 +228,29 @@ func (s *Service) Ingest(device *core.Record, in EventInput, raw any) (EventResu capturedAt := millisTime(in.CapturedAtMs, now, now) postTime := millisTime(n.PostTimeMs, capturedAt, now) whenTime := millisTime(n.WhenMs, postTime, now) - enrolledAt := device.GetDateTime("enrolled_at").Time() + enrolledAt := device.EnrolledAt if enrolledAt.IsZero() { - enrolledAt = device.GetDateTime("created").Time() + enrolledAt = device.CreatedAt } - // Only validated, allowlisted relay traffic refreshes readiness. Signature - // verification alone must not make a malformed request look healthy. - device.Set("last_seen_at", now) - if err := s.App.Save(device); err != nil { + // Preserve v1 behavior: valid allowlisted traffic refreshes last_seen even if downstream matching later fails. + if err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + current, err := uow.Relay().Get(device.ID) + if err != nil { + return err + } + if !current.Enabled { + return domain.New("UNKNOWN_RELAY_DEVICE", "relay device is not enrolled or is disabled", 401) + } + current.LastSeenAt = now + return uow.Relay().Save(current) + }); err != nil { return EventResult{}, err } var result EventResult var queued bool - err := s.App.RunInTransaction(func(tx core.App) error { - existing, findErr := tx.FindFirstRecordByFilter("relay_events", "device = {:device} && event_id = {:event}", dbx.Params{"device": device.Id, "event": in.EventID}) + err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + repo := uow.RelayEvents() + existing, findErr := repo.FindByDeviceEvent(device.ID, in.EventID) if findErr == nil { result = resultFromRelayEvent(existing) result.Duplicate = true @@ -250,88 +260,60 @@ func (s *Service) Ingest(device *core.Record, in EventInput, raw any) (EventResu if !errors.Is(findErr, sql.ErrNoRows) { return findErr } - c, err := tx.FindCollectionByNameOrId("relay_events") - if err != nil { - return err - } - event := core.NewRecord(c) - event.Set("device", device.Id) - event.Set("event_id", in.EventID) - event.Set("kind", in.Kind) - event.Set("app_package", n.PackageName) - event.Set("app_name", trimMax(n.AppName, 255)) - event.Set("notification_key", trimMax(n.Key, 512)) - event.Set("notification_id", n.ID) - event.Set("notification_tag", trimMax(n.Tag, 255)) - event.Set("group_key", trimMax(n.GroupKey, 512)) - event.Set("is_group_summary", n.IsGroupSummary) - event.Set("post_time", postTime) - event.Set("notification_when", whenTime) - event.Set("captured_at", capturedAt) - event.Set("channel_id", trimMax(n.ChannelID, 255)) - event.Set("category", trimMax(n.Category, 255)) - event.Set("title", n.Title) - event.Set("body", n.Text) - event.Set("big_text", n.BigText) - event.Set("sub_text", n.SubText) - event.Set("summary_text", n.SummaryText) - event.Set("text_lines", n.TextLines) - event.Set("custom_texts", n.CustomTexts) - event.Set("processing_status", "received") - if raw != nil { - event.Set("raw_payload", raw) - } - if err := tx.Save(event); err != nil { + event := &domain.RelayEvent{DeviceRecordID: device.ID, EventID: in.EventID, Kind: in.Kind, AppPackage: n.PackageName, AppName: trimMax(n.AppName, 255), NotificationKey: trimMax(n.Key, 512), NotificationID: n.ID, NotificationTag: trimMax(n.Tag, 255), GroupKey: trimMax(n.GroupKey, 512), IsGroupSummary: n.IsGroupSummary, PostTime: postTime, NotificationWhen: whenTime, CapturedAt: capturedAt, ChannelID: trimMax(n.ChannelID, 255), Category: trimMax(n.Category, 255), Title: n.Title, Body: n.Text, BigText: n.BigText, SubText: n.SubText, SummaryText: n.SummaryText, TextLines: append([]string(nil), n.TextLines...), CustomTexts: append([]string(nil), n.CustomTexts...), ProcessingStatus: "received", RawPayload: raw} + if err := repo.Create(event); err != nil { return err } - result.EventID = event.Id + result.EventID = event.ID if !enrolledAt.IsZero() && postTime.Before(enrolledAt.Add(-2*time.Minute)) { - event.Set("processing_status", "ignored") - event.Set("error", "notification predates relay enrollment") + event.ProcessingStatus = "ignored" + event.Error = "notification predates relay enrollment" result.Status = "ignored" result.Action = "ignored_pre_enrollment" - return tx.Save(event) + return repo.Save(event) } if n.IsGroupSummary { - event.Set("processing_status", "ignored") - event.Set("error", "group summary notification") + event.ProcessingStatus = "ignored" + event.Error = "group summary notification" result.Status = "ignored" result.Action = "ignored_group_summary" - return tx.Save(event) + return repo.Save(event) + } + if n.PackageName == GoogleMessagesPackage { + custom := strings.Join(n.CustomTexts, "\n") + shadowText := strings.TrimSpace(strings.Join([]string{n.Title, n.Text, n.BigText, custom, strings.Join(n.TextLines, "\n")}, "\n")) + annotation := evidenceshadow.Annotate(event, shadowText) + event.ProcessingStatus = "shadow_observed" + result.Status = "observed" + result.Action = "shadow_" + annotation.ParseStatus + return repo.Save(event) } if n.PackageName != PaytmBusinessPackage || s.Paytm == nil { - event.Set("processing_status", "observed") + event.ProcessingStatus = "observed" result.Status = "observed" result.Action = "observed_only" - return tx.Save(event) + return repo.Save(event) } custom := strings.Join(n.CustomTexts, "\n") - downstream, matchQueued, err := s.Paytm.IngestInApp(tx, paytmnotification.Input{ - Source: "android_relay", - SourceEventID: "android:" + device.GetString("device_id") + ":" + in.EventID, - AppPackage: n.PackageName, AppName: n.AppName, - Title: n.Title, Body: strings.TrimSpace(strings.Join([]string{n.Text, custom, strings.Join(n.TextLines, "\n")}, "\n")), - BigText: n.BigText, Channel: n.ChannelID, NotificationTime: whenTime, - RawPayload: map[string]any{"relayEventId": in.EventID, "deviceId": device.GetString("device_id")}, - }) + downstream, matchQueued, err := s.Paytm.IngestUoW(uow, paytmnotification.Input{Source: "android_relay", SourceEventID: "android:" + device.DeviceID + ":" + in.EventID, AppPackage: n.PackageName, AppName: n.AppName, Title: n.Title, Body: strings.TrimSpace(strings.Join([]string{n.Text, custom, strings.Join(n.TextLines, "\n")}, "\n")), BigText: n.BigText, Channel: n.ChannelID, NotificationTime: whenTime, RawPayload: map[string]any{"relayEventId": in.EventID, "deviceId": device.DeviceID}}) if err != nil { - event.Set("processing_status", "error") - event.Set("error", err.Error()) + event.ProcessingStatus = "error" + event.Error = err.Error() result.Status = "error" result.Action = "downstream_error" - _ = tx.Save(event) + _ = repo.Save(event) return err } queued = queued || matchQueued - event.Set("processing_status", "forwarded") - event.Set("downstream_event_id", downstream.EventID) - event.Set("matched_payment", downstream.PaymentID) - event.Set("provider_result", map[string]any{"status": downstream.Status, "action": downstream.Action, "duplicate": downstream.Duplicate}) + event.ProcessingStatus = "forwarded" + event.DownstreamEventID = downstream.EventID + event.MatchedPaymentID = downstream.PaymentID + event.ProviderResult = map[string]any{"status": downstream.Status, "action": downstream.Action, "duplicate": downstream.Duplicate} result.Status = downstream.Status result.Action = downstream.Action result.PaymentID = downstream.PaymentID result.Duplicate = downstream.Duplicate - return tx.Save(event) + return repo.Save(event) }) if err == nil && queued && s.Paytm != nil && s.Paytm.Payments != nil { s.Paytm.Payments.WakeWebhooks() @@ -389,8 +371,11 @@ func totalTextBytes(n Notification) int { } return total } -func resultFromRelayEvent(r *core.Record) EventResult { - return EventResult{EventID: r.Id, Status: r.GetString("processing_status"), PaymentID: r.GetString("matched_payment")} +func resultFromRelayEvent(r *domain.RelayEvent) EventResult { + if r == nil { + return EventResult{} + } + return EventResult{EventID: r.ID, Status: r.ProcessingStatus, PaymentID: r.MatchedPaymentID} } func CanonicalRequest(method, path, timestamp string, body []byte) string { diff --git a/internal/androidrelay/service_test.go b/internal/androidrelay/service_test.go index c91fd20..9f560df 100644 --- a/internal/androidrelay/service_test.go +++ b/internal/androidrelay/service_test.go @@ -1,6 +1,7 @@ package androidrelay import ( + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" @@ -18,6 +19,7 @@ import ( "github.com/Phloraxx/payment-api/internal/domain" "github.com/Phloraxx/payment-api/internal/payments" "github.com/Phloraxx/payment-api/internal/paytmnotification" + "github.com/Phloraxx/payment-api/internal/store" _ "github.com/Phloraxx/payment-api/migrations" "github.com/pocketbase/pocketbase/tests" ) @@ -37,6 +39,19 @@ func testKey(t *testing.T) (*ecdsa.PrivateKey, string, string) { return priv, hex.EncodeToString(sum[:]), string(block) } +func typedRelayDevice(t *testing.T, service *Service, recordID string) *domain.RelayDevice { + t.Helper() + var device *domain.RelayDevice + if err := service.Store.View(context.Background(), func(uow store.UnitOfWork) error { + var err error + device, err = uow.Relay().Get(recordID) + return err + }); err != nil { + t.Fatal(err) + } + return device +} + func TestEnrollVerifyAndIngestPaytmRemoteViewEvidence(t *testing.T) { app, err := tests.NewTestApp() if err != nil { @@ -129,7 +144,7 @@ func TestRelayObservesGPayWithoutMatching(t *testing.T) { if err != nil { t.Fatal(err) } - result, err := service.Ingest(device, EventInput{SchemaVersion: 1, EventID: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", Kind: "notification", Notification: Notification{PackageName: GPayPersonalPackage, Title: "You received money"}}, nil) + result, err := service.Ingest(typedRelayDevice(t, service, device.Id), EventInput{SchemaVersion: 1, EventID: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", Kind: "notification", Notification: Notification{PackageName: GPayPersonalPackage, Title: "You received money"}}, nil) if err != nil || result.Status != "observed" || result.Action != "observed_only" { t.Fatalf("result=%+v err=%v", result, err) } @@ -152,7 +167,7 @@ func TestRelayIgnoresNotificationThatPredatesEnrollment(t *testing.T) { if err != nil { t.Fatal(err) } - result, err := service.Ingest(device, EventInput{ + result, err := service.Ingest(typedRelayDevice(t, service, device.Id), EventInput{ SchemaVersion: 1, EventID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Kind: "notification", @@ -204,3 +219,56 @@ func TestVerifyAloneDoesNotRefreshRelayReadiness(t *testing.T) { t.Fatalf("signature verification alone refreshed last_seen_at: %s", device.GetDateTime("last_seen_at")) } } + +func TestRelayGoogleMessagesIsShadowOnlyAndCannotMatchPayment(t *testing.T) { + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + t.Cleanup(app.Cleanup) + now := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC) + cfg := config.Config{PaymentTTL: 20 * time.Minute, AmountQuarantine: 24 * time.Hour, TestMode: true} + paymentService := payments.NewService(app, cfg, nil) + paymentService.Now = func() time.Time { return now } + paymentService.SuffixStart = func() (int64, error) { return 1, nil } + payment, _, err := paymentService.Create(payments.CreateInput{AmountRupees: 100, PaymentAccount: "kotak"}) + if err != nil { + t.Fatal(err) + } + service := NewService(app, nil) + service.Now = func() time.Time { return now } + _, deviceID, publicPEM := testKey(t) + if _, err := service.Enroll(EnrollmentInput{DeviceID: deviceID, Name: "Phone", PublicKeyPEM: publicPEM}); err != nil { + t.Fatal(err) + } + record, err := app.FindFirstRecordByFilter("relay_devices", "device_id={:id}", map[string]any{"id": deviceID}) + if err != nil { + t.Fatal(err) + } + device := typedRelayDevice(t, service, record.Id) + result, err := service.Ingest(device, EventInput{SchemaVersion: 1, EventID: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", Kind: "notification", CapturedAtMs: now.UnixMilli(), Notification: Notification{PackageName: GoogleMessagesPackage, AppName: "Messages", PostTimeMs: now.UnixMilli(), WhenMs: now.UnixMilli(), Title: "Bank", Text: "Received Rs.100.01 from Person UPI Ref:123456789012"}}, nil) + if err != nil { + t.Fatal(err) + } + if result.Status != "observed" || result.Action != "shadow_complete" || result.PaymentID != "" { + t.Fatalf("shadow result=%+v", result) + } + stored, err := paymentService.Get(payment.ID) + if err != nil { + t.Fatal(err) + } + if stored.Status != domain.StatusPending || stored.RRN != "" || stored.EvidenceSource != "" { + t.Fatalf("shadow evidence mutated payment: %+v", stored) + } + var relayEvent *domain.RelayEvent + if err := service.Store.View(context.Background(), func(uow store.UnitOfWork) error { + var loadErr error + relayEvent, loadErr = uow.RelayEvents().FindByDeviceEvent(device.ID, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") + return loadErr + }); err != nil { + t.Fatal(err) + } + if relayEvent.ProcessingStatus != "shadow_observed" || relayEvent.ProviderResult == nil { + t.Fatalf("relay event=%+v", relayEvent) + } +} diff --git a/internal/androidrelay/status.go b/internal/androidrelay/status.go index 3d7e591..fb861e0 100644 --- a/internal/androidrelay/status.go +++ b/internal/androidrelay/status.go @@ -1,13 +1,14 @@ package androidrelay import ( + "context" + "database/sql" + "errors" "strings" "time" "github.com/Phloraxx/payment-api/internal/domain" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/types" + "github.com/Phloraxx/payment-api/internal/store" ) const defaultStaleAfter = time.Hour @@ -19,6 +20,10 @@ type HeartbeatInput struct { DeviceModel string `json:"deviceModel"` NotificationAccess bool `json:"notificationAccess"` ListenerConnected bool `json:"listenerConnected"` + BatteryOptimizationExempt *bool `json:"batteryOptimizationExempt"` + PowerSaveMode *bool `json:"powerSaveMode"` + BackgroundRestricted *bool `json:"backgroundRestricted"` + ForegroundService *bool `json:"foregroundService"` PendingCount int `json:"pendingCount"` FailedCount int `json:"failedCount"` LastSuccessfulDeliveryAtMs int64 `json:"lastSuccessfulDeliveryAtMs"` @@ -32,45 +37,52 @@ type HeartbeatResult struct { } type DeviceStatus struct { - ID string `json:"id"` - DeviceID string `json:"deviceId"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - AppVersion string `json:"appVersion"` - AndroidVersion string `json:"androidVersion"` - DeviceModel string `json:"deviceModel"` - LastSeenAt any `json:"lastSeenAt"` - LastHeartbeatAt any `json:"lastHeartbeatAt"` - HeartbeatGraceUntil any `json:"heartbeatGraceUntil"` - NotificationAccess bool `json:"notificationAccess"` - ListenerConnected bool `json:"listenerConnected"` - PendingCount int `json:"pendingCount"` - FailedCount int `json:"failedCount"` - LastClientError string `json:"lastClientError,omitempty"` - LastDeliveryAt any `json:"lastDeliveryAt"` - LastEventAt any `json:"lastEventAt"` - LastMatchedAt any `json:"lastMatchedAt"` - LastMatchedPaymentID string `json:"lastMatchedPaymentId,omitempty"` - RecentErrorCount int64 `json:"recentErrorCount"` - Active bool `json:"active"` + ID string `json:"id"` + DeviceID string `json:"deviceId"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + AppVersion string `json:"appVersion"` + AndroidVersion string `json:"androidVersion"` + DeviceModel string `json:"deviceModel"` + LastSeenAt any `json:"lastSeenAt"` + LastHeartbeatAt any `json:"lastHeartbeatAt"` + HeartbeatGraceUntil any `json:"heartbeatGraceUntil"` + NotificationAccess bool `json:"notificationAccess"` + ListenerConnected bool `json:"listenerConnected"` + PowerHealthReported bool `json:"powerHealthReported"` + BatteryOptimizationExempt bool `json:"batteryOptimizationExempt"` + PowerSaveMode bool `json:"powerSaveMode"` + BackgroundRestricted bool `json:"backgroundRestricted"` + ForegroundService bool `json:"foregroundService"` + PowerHealthy bool `json:"powerHealthy"` + PendingCount int `json:"pendingCount"` + FailedCount int `json:"failedCount"` + LastClientError string `json:"lastClientError,omitempty"` + LastDeliveryAt any `json:"lastDeliveryAt"` + LastEventAt any `json:"lastEventAt"` + LastMatchedAt any `json:"lastMatchedAt"` + LastMatchedPaymentID string `json:"lastMatchedPaymentId,omitempty"` + RecentErrorCount int64 `json:"recentErrorCount"` + Active bool `json:"active"` } type Status struct { - Ready bool `json:"ready"` - EnabledDevices int `json:"enabledDevices"` - ActiveDevices int `json:"activeDevices"` - LegacyGraceDevices int `json:"legacyGraceDevices"` - StaleAfterSeconds int64 `json:"staleAfterSeconds"` - LastSeenAt any `json:"lastSeenAt"` - LastHeartbeatAt any `json:"lastHeartbeatAt"` - LastEventAt any `json:"lastEventAt"` - LastMatchedAt any `json:"lastMatchedAt"` - RecentErrorCount int64 `json:"recentErrorCount"` - PendingQueueCount int `json:"pendingQueueCount"` - FailedQueueCount int `json:"failedQueueCount"` + Ready bool `json:"ready"` + EnabledDevices int `json:"enabledDevices"` + ActiveDevices int `json:"activeDevices"` + LegacyGraceDevices int `json:"legacyGraceDevices"` + StaleAfterSeconds int64 `json:"staleAfterSeconds"` + LastSeenAt any `json:"lastSeenAt"` + LastHeartbeatAt any `json:"lastHeartbeatAt"` + LastEventAt any `json:"lastEventAt"` + LastMatchedAt any `json:"lastMatchedAt"` + RecentErrorCount int64 `json:"recentErrorCount"` + PendingQueueCount int `json:"pendingQueueCount"` + FailedQueueCount int `json:"failedQueueCount"` + PowerUnhealthyDevices int `json:"powerUnhealthyDevices"` } -func (s *Service) Heartbeat(device *core.Record, in HeartbeatInput) (HeartbeatResult, error) { +func (s *Service) Heartbeat(device *domain.RelayDevice, in HeartbeatInput) (HeartbeatResult, error) { if device == nil { return HeartbeatResult{}, domain.New("UNKNOWN_RELAY_DEVICE", "relay device is not enrolled or is disabled", 401) } @@ -84,52 +96,70 @@ func (s *Service) Heartbeat(device *core.Record, in HeartbeatInput) (HeartbeatRe return HeartbeatResult{}, domain.New("INVALID_RELAY_HEARTBEAT", "last successful delivery time is invalid", 400) } now := s.now() - if in.LastSuccessfulDeliveryAtMs > 0 { - lastDelivery := time.UnixMilli(in.LastSuccessfulDeliveryAtMs) - if lastDelivery.After(now.Add(5 * time.Minute)) { - return HeartbeatResult{}, domain.New("INVALID_RELAY_HEARTBEAT", "last successful delivery time is in the future", 400) + var result HeartbeatResult + err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + current, err := uow.Relay().Get(device.ID) + if err != nil || current == nil || !current.Enabled { + return domain.New("UNKNOWN_RELAY_DEVICE", "relay device is not enrolled or is disabled", 401) } - device.Set("last_client_delivery_at", lastDelivery) - } - device.Set("app_version", trimMax(in.AppVersion, 64)) - device.Set("android_version", trimMax(in.AndroidVersion, 64)) - device.Set("device_model", trimMax(in.DeviceModel, 255)) - device.Set("notification_access", in.NotificationAccess) - device.Set("listener_connected", in.ListenerConnected) - device.Set("pending_count", in.PendingCount) - device.Set("failed_count", in.FailedCount) - device.Set("last_client_error", trimMax(in.LastClientError, 1024)) - device.Set("last_heartbeat_at", now) - device.Set("last_seen_at", now) - if err := s.App.Save(device); err != nil { - return HeartbeatResult{}, err - } - return HeartbeatResult{DeviceID: device.GetString("device_id"), Enabled: device.GetBool("enabled"), ServerTime: now.Format(time.RFC3339Nano)}, nil + if in.LastSuccessfulDeliveryAtMs > 0 { + lastDelivery := time.UnixMilli(in.LastSuccessfulDeliveryAtMs) + if lastDelivery.After(now.Add(5 * time.Minute)) { + return domain.New("INVALID_RELAY_HEARTBEAT", "last successful delivery time is in the future", 400) + } + current.LastClientDeliveryAt = lastDelivery + } + current.AppVersion = trimMax(in.AppVersion, 64) + current.AndroidVersion = trimMax(in.AndroidVersion, 64) + current.DeviceModel = trimMax(in.DeviceModel, 255) + current.NotificationAccess = in.NotificationAccess + current.ListenerConnected = in.ListenerConnected + if in.BatteryOptimizationExempt != nil { + current.BatteryOptimizationExempt = *in.BatteryOptimizationExempt + } + if in.PowerSaveMode != nil { + current.PowerSaveMode = *in.PowerSaveMode + } + if in.BackgroundRestricted != nil { + current.BackgroundRestricted = *in.BackgroundRestricted + } + if in.ForegroundService != nil { + current.ForegroundServiceActive = *in.ForegroundService + } + current.PowerHealthReported = in.BatteryOptimizationExempt != nil && in.PowerSaveMode != nil && in.BackgroundRestricted != nil && in.ForegroundService != nil + current.PendingCount = in.PendingCount + current.FailedCount = in.FailedCount + current.LastClientError = trimMax(in.LastClientError, 1024) + current.LastHeartbeatAt = now + current.LastSeenAt = now + if err := uow.Relay().Save(current); err != nil { + return err + } + result = HeartbeatResult{DeviceID: current.DeviceID, Enabled: current.Enabled, ServerTime: now.Format(time.RFC3339Nano)} + return nil + }) + return result, err } func (s *Service) Ready(staleAfter time.Duration) (bool, error) { - return s.ReadyInApp(s.App, staleAfter) + var ready bool + err := s.Store.View(context.Background(), func(uow store.UnitOfWork) error { + var err error + ready, err = s.ReadyUoW(uow, staleAfter) + return err + }) + return ready, err } -func (s *Service) ReadyInApp(app core.App, staleAfter time.Duration) (bool, error) { +func (s *Service) ReadyUoW(uow store.UnitOfWork, staleAfter time.Duration) (bool, error) { staleAfter = normalizeStaleAfter(staleAfter) now := s.now() - cutoff := now.Add(-staleAfter) - devices, err := app.FindRecordsByFilter("relay_devices", "enabled = true", "-last_seen_at", 100, 0) + devices, err := uow.Relay().EnabledDevices(100) if err != nil { return false, err } for _, device := range devices { - heartbeat := device.GetDateTime("last_heartbeat_at").Time() - if heartbeat.IsZero() { - graceUntil := device.GetDateTime("heartbeat_grace_until").Time() - if !graceUntil.IsZero() && now.Before(graceUntil) { - return true, nil - } - continue - } - seen := device.GetDateTime("last_seen_at").Time() - if !seen.IsZero() && !seen.Before(cutoff) && device.GetBool("notification_access") && device.GetBool("listener_connected") { + if device.Ready(now, staleAfter) { return true, nil } } @@ -139,138 +169,129 @@ func (s *Service) ReadyInApp(app core.App, staleAfter time.Duration) (bool, erro func (s *Service) Status(staleAfter time.Duration) (Status, error) { staleAfter = normalizeStaleAfter(staleAfter) now := s.now() - cutoff := now.Add(-staleAfter) - devices, err := s.App.FindRecordsByFilter("relay_devices", "enabled = true", "-last_seen_at", 0, 0) - if err != nil { - return Status{}, err - } - status := Status{EnabledDevices: len(devices), StaleAfterSeconds: int64(staleAfter / time.Second)} - for _, device := range devices { - seen := device.GetDateTime("last_seen_at").Time() - heartbeat := device.GetDateTime("last_heartbeat_at").Time() - if status.LastSeenAt == nil && !seen.IsZero() { - status.LastSeenAt = timeValue(seen) + var status Status + err := s.Store.View(context.Background(), func(uow store.UnitOfWork) error { + devices, err := uow.Relay().All(100) + if err != nil { + return err } - if status.LastHeartbeatAt == nil && !heartbeat.IsZero() { - status.LastHeartbeatAt = timeValue(heartbeat) - } - // Existing v0.2 devices get a bounded migration grace so API deployment - // cannot strand checkout before the phone is upgraded. New enrollments do - // not get this grace. Once any heartbeat is received, normal stale/listener - // readiness applies immediately. - if heartbeat.IsZero() { - graceUntil := device.GetDateTime("heartbeat_grace_until").Time() - if !graceUntil.IsZero() && now.Before(graceUntil) { + status.StaleAfterSeconds = int64(staleAfter / time.Second) + for _, device := range devices { + if !device.Enabled { + continue + } + status.EnabledDevices++ + if status.LastSeenAt == nil && !device.LastSeenAt.IsZero() { + status.LastSeenAt = timeValue(device.LastSeenAt) + } + if status.LastHeartbeatAt == nil && !device.LastHeartbeatAt.IsZero() { + status.LastHeartbeatAt = timeValue(device.LastHeartbeatAt) + } + health := device.Health() + if health.LegacyGraceActive(now) || health.PowerTelemetryGraceActive(now, staleAfter) { status.ActiveDevices++ status.LegacyGraceDevices++ + } else if health.CurrentReady(now, staleAfter) { + status.ActiveDevices++ + } + if !health.PowerReady() { + status.PowerUnhealthyDevices++ } - } else if !seen.IsZero() && !seen.Before(cutoff) && device.GetBool("notification_access") && device.GetBool("listener_connected") { - status.ActiveDevices++ + status.PendingQueueCount += device.PendingCount + status.FailedQueueCount += device.FailedCount } - status.PendingQueueCount += device.GetInt("pending_count") - status.FailedQueueCount += device.GetInt("failed_count") - } - status.Ready = status.ActiveDevices > 0 - - if latest, findErr := s.App.FindRecordsByFilter("relay_events", "", "-created", 1, 0); findErr == nil && len(latest) == 1 { - status.LastEventAt = timeValue(latest[0].GetDateTime("created").Time()) - } else if findErr != nil { - return Status{}, findErr - } - if matched, findErr := s.App.FindRecordsByFilter("relay_events", "matched_payment != ''", "-created", 1, 0); findErr == nil && len(matched) == 1 { - status.LastMatchedAt = timeValue(matched[0].GetDateTime("created").Time()) - } else if findErr != nil { - return Status{}, findErr - } - errorCutoff := filterDate(now.Add(-24 * time.Hour)) - errorCount, err := s.App.CountRecords("relay_events", dbx.NewExp("processing_status = 'error' AND created >= {:cutoff}", dbx.Params{"cutoff": errorCutoff})) - if err != nil { - return Status{}, err - } - status.RecentErrorCount = errorCount - return status, nil + status.Ready = status.ActiveDevices > 0 + if latest, err := uow.RelayEvents().Latest(""); err == nil { + status.LastEventAt = timeValue(latest.CreatedAt) + } else if !errors.Is(err, sql.ErrNoRows) { + return err + } + if matched, err := uow.RelayEvents().LatestMatched(""); err == nil { + status.LastMatchedAt = timeValue(matched.CreatedAt) + } else if !errors.Is(err, sql.ErrNoRows) { + return err + } + count, err := uow.RelayEvents().CountErrorsSince("", now.Add(-24*time.Hour)) + if err != nil { + return err + } + status.RecentErrorCount = count + return nil + }) + return status, err } func (s *Service) Devices(staleAfter time.Duration) ([]DeviceStatus, error) { staleAfter = normalizeStaleAfter(staleAfter) - cutoff := s.now().Add(-staleAfter) - records, err := s.App.FindRecordsByFilter("relay_devices", "", "-last_seen_at,-created", 100, 0) - if err != nil { - return nil, err - } - result := make([]DeviceStatus, 0, len(records)) - for _, record := range records { - seen := record.GetDateTime("last_seen_at").Time() - heartbeat := record.GetDateTime("last_heartbeat_at").Time() - graceUntil := record.GetDateTime("heartbeat_grace_until").Time() - legacyGraceActive := heartbeat.IsZero() && !graceUntil.IsZero() && s.now().Before(graceUntil) - listenerOK := !heartbeat.IsZero() && record.GetBool("notification_access") && record.GetBool("listener_connected") - lastEventAt, lastMatchedAt, lastMatchedPaymentID, recentErrorCount, statusErr := s.deviceEventStatus(record.Id, s.now()) - if statusErr != nil { - return nil, statusErr + now := s.now() + var result []DeviceStatus + err := s.Store.View(context.Background(), func(uow store.UnitOfWork) error { + devices, err := uow.Relay().All(100) + if err != nil { + return err } - result = append(result, DeviceStatus{ - ID: record.Id, DeviceID: record.GetString("device_id"), Name: record.GetString("name"), Enabled: record.GetBool("enabled"), - AppVersion: record.GetString("app_version"), AndroidVersion: record.GetString("android_version"), DeviceModel: record.GetString("device_model"), - LastSeenAt: timeValue(seen), LastHeartbeatAt: timeValue(heartbeat), HeartbeatGraceUntil: timeValue(graceUntil), NotificationAccess: record.GetBool("notification_access"), ListenerConnected: record.GetBool("listener_connected"), - PendingCount: record.GetInt("pending_count"), FailedCount: record.GetInt("failed_count"), LastClientError: record.GetString("last_client_error"), - LastDeliveryAt: timeValue(record.GetDateTime("last_client_delivery_at").Time()), - LastEventAt: lastEventAt, LastMatchedAt: lastMatchedAt, LastMatchedPaymentID: lastMatchedPaymentID, RecentErrorCount: recentErrorCount, - Active: record.GetBool("enabled") && (legacyGraceActive || (!seen.IsZero() && !seen.Before(cutoff) && listenerOK)), - }) - } - return result, nil + result = make([]DeviceStatus, 0, len(devices)) + for _, device := range devices { + stats, err := relayEventStatus(uow, device.ID, now) + if err != nil { + return err + } + health := device.Health() + result = append(result, DeviceStatus{ID: device.ID, DeviceID: device.DeviceID, Name: device.Name, Enabled: device.Enabled, AppVersion: device.AppVersion, AndroidVersion: device.AndroidVersion, DeviceModel: device.DeviceModel, LastSeenAt: timeValue(device.LastSeenAt), LastHeartbeatAt: timeValue(device.LastHeartbeatAt), HeartbeatGraceUntil: timeValue(device.HeartbeatGraceUntil), NotificationAccess: device.NotificationAccess, ListenerConnected: device.ListenerConnected, PowerHealthReported: device.PowerHealthReported, BatteryOptimizationExempt: device.BatteryOptimizationExempt, PowerSaveMode: device.PowerSaveMode, BackgroundRestricted: device.BackgroundRestricted, ForegroundService: device.ForegroundServiceActive, PowerHealthy: health.PowerReady(), PendingCount: device.PendingCount, FailedCount: device.FailedCount, LastClientError: device.LastClientError, LastDeliveryAt: timeValue(device.LastClientDeliveryAt), LastEventAt: timeValue(stats.LastEventAt), LastMatchedAt: timeValue(stats.LastMatchedAt), LastMatchedPaymentID: stats.LastMatchedPaymentID, RecentErrorCount: stats.RecentErrorCount, Active: health.Ready(now, staleAfter)}) + } + return nil + }) + return result, err } -func (s *Service) deviceEventStatus(deviceRecordID string, now time.Time) (lastEventAt any, lastMatchedAt any, lastMatchedPaymentID string, recentErrorCount int64, err error) { - params := dbx.Params{"device": deviceRecordID} - latest, err := s.App.FindRecordsByFilter("relay_events", "device = {:device}", "-created", 1, 0, params) - if err != nil { - return nil, nil, "", 0, err +func relayEventStatus(uow store.UnitOfWork, deviceRecordID string, now time.Time) (domain.RelayEventStats, error) { + var stats domain.RelayEventStats + if latest, err := uow.RelayEvents().Latest(deviceRecordID); err == nil { + stats.LastEventAt = latest.CreatedAt + } else if !errors.Is(err, sql.ErrNoRows) { + return stats, err } - if len(latest) == 1 { - lastEventAt = timeValue(latest[0].GetDateTime("created").Time()) + if matched, err := uow.RelayEvents().LatestMatched(deviceRecordID); err == nil { + stats.LastMatchedAt = matched.CreatedAt + stats.LastMatchedPaymentID = matched.MatchedPaymentID + } else if !errors.Is(err, sql.ErrNoRows) { + return stats, err } - matched, err := s.App.FindRecordsByFilter("relay_events", "device = {:device} && matched_payment != ''", "-created", 1, 0, params) + count, err := uow.RelayEvents().CountErrorsSince(deviceRecordID, now.Add(-24*time.Hour)) if err != nil { - return nil, nil, "", 0, err - } - if len(matched) == 1 { - lastMatchedAt = timeValue(matched[0].GetDateTime("created").Time()) - lastMatchedPaymentID = matched[0].GetString("matched_payment") + return stats, err } - errorCutoff := filterDate(now.Add(-24 * time.Hour)) - recentErrorCount, err = s.App.CountRecords("relay_events", dbx.NewExp("device = {:device} AND processing_status = 'error' AND created >= {:cutoff}", dbx.Params{"device": deviceRecordID, "cutoff": errorCutoff})) - if err != nil { - return nil, nil, "", 0, err - } - return lastEventAt, lastMatchedAt, lastMatchedPaymentID, recentErrorCount, nil + stats.RecentErrorCount = count + return stats, nil } -func (s *Service) SetEnabled(recordID string, enabled bool) (*core.Record, error) { - return s.SetEnabledInApp(s.App, recordID, enabled) +func (s *Service) SetEnabled(recordID string, enabled bool) (*domain.RelayDevice, error) { + var result *domain.RelayDevice + err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + var err error + result, err = s.SetEnabledUoW(uow, recordID, enabled) + return err + }) + return result, err } -func (s *Service) SetEnabledInApp(app core.App, recordID string, enabled bool) (*core.Record, error) { +func (s *Service) SetEnabledUoW(uow store.UnitOfWork, recordID string, enabled bool) (*domain.RelayDevice, error) { recordID = strings.TrimSpace(recordID) if recordID == "" { return nil, domain.New("INVALID_RELAY_DEVICE_ID", "relay device id is required", 400) } - record, err := app.FindRecordById("relay_devices", recordID) + device, err := uow.Relay().Get(recordID) if err != nil { return nil, domain.New("RELAY_DEVICE_NOT_FOUND", "relay device was not found", 404) } - if record.GetBool("enabled") != enabled { - // Operator state changes revoke migration-only compatibility. A device - // that is explicitly disabled and later re-enabled must prove current - // health with a v0.3 heartbeat rather than inheriting old rollout grace. - record.Set("heartbeat_grace_until", "") + if device.Enabled != enabled { + device.HeartbeatGraceUntil = time.Time{} } - record.Set("enabled", enabled) - if err := app.Save(record); err != nil { + device.Enabled = enabled + if err := uow.Relay().Save(device); err != nil { return nil, err } - return record, nil + return device, nil } func normalizeStaleAfter(value time.Duration) time.Duration { @@ -286,11 +307,3 @@ func timeValue(value time.Time) any { } return value.UTC().Format(time.RFC3339Nano) } - -func filterDate(value time.Time) string { - parsed, err := types.ParseDateTime(value.UTC()) - if err != nil { - return value.UTC().Format(time.RFC3339Nano) - } - return parsed.String() -} diff --git a/internal/androidrelay/status_test.go b/internal/androidrelay/status_test.go index 019f4fa..66b1a23 100644 --- a/internal/androidrelay/status_test.go +++ b/internal/androidrelay/status_test.go @@ -50,7 +50,7 @@ func TestRelayStatusAndHeartbeatTrackReadiness(t *testing.T) { t.Fatalf("legacy heartbeat grace should be ready: %+v", status) } - if _, err := service.Heartbeat(device, HeartbeatInput{SchemaVersion: 1, AppVersion: "0.3.0", NotificationAccess: false}); err != nil { + if _, err := service.Heartbeat(typedRelayDevice(t, service, device.Id), HeartbeatInput{SchemaVersion: 1, AppVersion: "0.3.0", NotificationAccess: false}); err != nil { t.Fatal(err) } status, _ = service.Status(time.Hour) @@ -58,7 +58,7 @@ func TestRelayStatusAndHeartbeatTrackReadiness(t *testing.T) { t.Fatalf("heartbeat without notification access must make relay unavailable: %+v", status) } - if _, err := service.Heartbeat(device, HeartbeatInput{SchemaVersion: 1, AppVersion: "0.3.0", NotificationAccess: true, ListenerConnected: true, PendingCount: 2, FailedCount: 1, LastSuccessfulDeliveryAtMs: now.Add(-time.Minute).UnixMilli()}); err != nil { + if _, err := service.Heartbeat(typedRelayDevice(t, service, device.Id), HeartbeatInput{SchemaVersion: 1, AppVersion: "0.3.0", NotificationAccess: true, ListenerConnected: true, PendingCount: 2, FailedCount: 1, LastSuccessfulDeliveryAtMs: now.Add(-time.Minute).UnixMilli()}); err != nil { t.Fatal(err) } status, _ = service.Status(time.Hour) @@ -176,7 +176,7 @@ func TestRelayDeviceStateChangeClearsLegacyGrace(t *testing.T) { if err != nil { t.Fatal(err) } - if !disabled.GetDateTime("heartbeat_grace_until").Time().IsZero() { + if !disabled.HeartbeatGraceUntil.IsZero() { t.Fatal("disabling a device must clear legacy heartbeat grace") } if _, err := service.SetEnabled(device.Id, true); err != nil { @@ -209,7 +209,7 @@ func TestHeartbeatRejectsFutureDeliveryTimestamp(t *testing.T) { if err := app.Save(device); err != nil { t.Fatal(err) } - _, err = service.Heartbeat(device, HeartbeatInput{SchemaVersion: 1, LastSuccessfulDeliveryAtMs: now.Add(6 * time.Minute).UnixMilli()}) + _, err = service.Heartbeat(typedRelayDevice(t, service, device.Id), HeartbeatInput{SchemaVersion: 1, LastSuccessfulDeliveryAtMs: now.Add(6 * time.Minute).UnixMilli()}) if err == nil { t.Fatal("expected future delivery timestamp to be rejected") } @@ -242,3 +242,121 @@ func TestRelayStatusMarksStaleDeviceInactive(t *testing.T) { t.Fatalf("stale device status = %+v", status) } } + +func TestV031PowerHealthGatesReadinessButAllowsPowerSaver(t *testing.T) { + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + defer app.Cleanup() + now := time.Date(2026, 8, 28, 7, 30, 0, 0, time.UTC) + service := NewService(app, nil) + service.Now = func() time.Time { return now } + collection, _ := app.FindCollectionByNameOrId("relay_devices") + device := core.NewRecord(collection) + device.Set("device_id", "abababababababababababababababababababababababababababababababab") + device.Set("name", "Always-on phone") + device.Set("public_key_pem", "test-key") + device.Set("enabled", true) + if err := app.Save(device); err != nil { + t.Fatal(err) + } + + heartbeat := func(exempt, saver, restricted, foreground bool) { + t.Helper() + if _, err := service.Heartbeat(typedRelayDevice(t, service, device.Id), HeartbeatInput{ + SchemaVersion: 1, + AppVersion: "0.3.1", + NotificationAccess: true, + ListenerConnected: true, + BatteryOptimizationExempt: boolPointer(exempt), + PowerSaveMode: boolPointer(saver), + BackgroundRestricted: boolPointer(restricted), + ForegroundService: boolPointer(foreground), + }); err != nil { + t.Fatal(err) + } + } + + heartbeat(true, true, false, true) + status, err := service.Status(time.Hour) + if err != nil { + t.Fatal(err) + } + if !status.Ready || status.ActiveDevices != 1 || status.PowerUnhealthyDevices != 0 { + t.Fatalf("power saver should remain ready when v0.3.1 is exempt and foreground: %+v", status) + } + devices, err := service.Devices(time.Hour) + if err != nil { + t.Fatal(err) + } + if len(devices) != 1 || !devices[0].PowerHealthReported || !devices[0].PowerHealthy || !devices[0].BatteryOptimizationExempt || !devices[0].PowerSaveMode || !devices[0].ForegroundService { + t.Fatalf("unexpected power health: %+v", devices) + } + + heartbeat(false, true, false, true) + status, _ = service.Status(time.Hour) + if status.Ready || status.PowerUnhealthyDevices != 1 { + t.Fatalf("battery-optimized v0.3.1 must fail closed: %+v", status) + } + + heartbeat(true, true, true, true) + status, _ = service.Status(time.Hour) + if status.Ready { + t.Fatalf("background-restricted v0.3.1 must fail closed: %+v", status) + } + + heartbeat(true, true, false, false) + status, _ = service.Status(time.Hour) + if status.Ready { + t.Fatalf("v0.3.1 without foreground runtime must fail closed: %+v", status) + } + + heartbeat(true, true, false, true) + status, _ = service.Status(time.Hour) + if !status.Ready { + t.Fatalf("healthy always-on state should recover readiness: %+v", status) + } +} + +func boolPointer(value bool) *bool { return &value } + +func TestPowerTelemetryCutoverGraceCountsDeviceActive(t *testing.T) { + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + defer app.Cleanup() + now := time.Date(2026, 8, 29, 13, 30, 0, 0, time.UTC) + service := NewService(app, nil) + service.Now = func() time.Time { return now } + collection, _ := app.FindCollectionByNameOrId("relay_devices") + device := core.NewRecord(collection) + device.Set("device_id", "abababababababababababababababababababababababababababababababab") + device.Set("name", "Cutover phone") + device.Set("public_key_pem", "test-key") + device.Set("enabled", true) + device.Set("app_version", "0.3.1") + device.Set("last_seen_at", now.Add(-time.Minute)) + device.Set("last_heartbeat_at", now.Add(-time.Minute)) + device.Set("heartbeat_grace_until", now.Add(2*time.Hour)) + device.Set("notification_access", true) + device.Set("listener_connected", true) + if err := app.Save(device); err != nil { + t.Fatal(err) + } + status, err := service.Status(time.Hour) + if err != nil { + t.Fatal(err) + } + if !status.Ready || status.ActiveDevices != 1 || status.LegacyGraceDevices != 1 || status.PowerUnhealthyDevices != 1 { + t.Fatalf("cutover grace status = %+v", status) + } + devices, err := service.Devices(time.Hour) + if err != nil { + t.Fatal(err) + } + if len(devices) != 1 || !devices[0].Active || devices[0].PowerHealthy { + t.Fatalf("cutover grace device = %+v", devices) + } +} diff --git a/internal/api/android_relay.go b/internal/api/android_relay.go index f0c6be6..08fa92a 100644 --- a/internal/api/android_relay.go +++ b/internal/api/android_relay.go @@ -9,6 +9,7 @@ import ( "github.com/Phloraxx/payment-api/internal/androidrelay" "github.com/Phloraxx/payment-api/internal/audit" "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/store" "github.com/pocketbase/pocketbase/core" ) @@ -119,9 +120,9 @@ func (a *API) setRelayDeviceEnabled(e *core.RequestEvent) error { if body.Enabled == nil { return e.BadRequestError("enabled must be true or false", nil) } - var record *core.Record - err := e.App.RunInTransaction(func(tx core.App) error { - updated, updateErr := a.AndroidRelay.SetEnabledInApp(tx, e.Request.PathValue("id"), *body.Enabled) + var device *domain.RelayDevice + err := a.AndroidRelay.Store.Write(e.Request.Context(), func(uow store.UnitOfWork) error { + updated, updateErr := a.AndroidRelay.SetEnabledUoW(uow, e.Request.PathValue("id"), *body.Enabled) if updateErr != nil { return updateErr } @@ -129,13 +130,10 @@ func (a *API) setRelayDeviceEnabled(e *core.RequestEvent) error { if auditService == nil { auditService = audit.NewService(e.App) } - if auditErr := auditService.RecordInApp(tx, audit.Entry{ - Action: "relay_device.enabled_changed", Actor: a.actor(e), EntityType: "relay_device", EntityID: updated.Id, - Summary: "Android relay device enabled state changed", Details: map[string]any{"enabled": *body.Enabled, "deviceName": updated.GetString("name")}, - }); auditErr != nil { + if auditErr := auditService.RecordUoW(uow, audit.Entry{Action: "relay_device.enabled_changed", Actor: a.actor(e), EntityType: "relay_device", EntityID: updated.ID, Summary: "Android relay device enabled state changed", Details: map[string]any{"enabled": *body.Enabled, "deviceName": updated.Name}}); auditErr != nil { return auditErr } - record = updated + device = updated return nil }) if err != nil { @@ -144,10 +142,10 @@ func (a *API) setRelayDeviceEnabled(e *core.RequestEvent) error { } return e.InternalServerError("failed to update relay device", err) } - return e.JSON(http.StatusOK, map[string]any{"id": record.Id, "enabled": record.GetBool("enabled")}) + return e.JSON(http.StatusOK, map[string]any{"id": device.ID, "enabled": device.Enabled}) } -func (a *API) verifyAndroidRelayRequest(e *core.RequestEvent) (*core.Record, []byte, error) { +func (a *API) verifyAndroidRelayRequest(e *core.RequestEvent) (*domain.RelayDevice, []byte, error) { if a.AndroidRelay == nil { return nil, nil, e.NotFoundError("route not found", nil) } diff --git a/internal/api/api.go b/internal/api/api.go index d3dbaff..2698eb1 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -13,6 +13,7 @@ import ( "net/http" "strconv" "strings" + "sync" "time" "github.com/Phloraxx/payment-api/internal/alerts" @@ -32,23 +33,25 @@ import ( "github.com/Phloraxx/payment-api/internal/refunds" "github.com/Phloraxx/payment-api/internal/reviews" "github.com/Phloraxx/payment-api/internal/sms" + "github.com/Phloraxx/payment-api/internal/store" appweb "github.com/Phloraxx/payment-api/internal/web" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" ) const ( - maxPaymentRequestBytes int64 = (1 << 20) + (64 << 10) - maxSMSRequestBytes int64 = 128 << 10 - maxPaytmNotificationRequestBytes int64 = 160 << 10 - maxEmailRequestBytes int64 = ((paymentemail.MaxRawBytes + 2) / 3 * 4) + (128 << 10) - maxGMessagesPairBytes int64 = 128 << 10 - maxReviewRequestBytes int64 = 16 << 10 - maxRefundRequestBytes int64 = (1 << 20) + (64 << 10) - maxStatementRequestBytes int64 = reconciliation.MaxFileBytes + (1 << 20) - maxRazorpayTestRequestBytes int64 = 1 << 20 - maxRazorpayLiveRequestBytes int64 = 1 << 20 - robotsTagValue = "noindex, nofollow, noarchive, nosnippet, noimageindex" + maxPaymentRequestBytes int64 = (1 << 20) + (64 << 10) + maxSMSRequestBytes int64 = 128 << 10 + maxPaytmNotificationRequestBytes int64 = 160 << 10 + maxEmailRequestBytes int64 = ((paymentemail.MaxRawBytes + 2) / 3 * 4) + (128 << 10) + maxGMessagesPairBytes int64 = 128 << 10 + maxReviewRequestBytes int64 = 16 << 10 + maxOperatorPaymentProfileRequestBytes int64 = 512 << 10 + maxRefundRequestBytes int64 = (1 << 20) + (64 << 10) + maxStatementRequestBytes int64 = reconciliation.MaxFileBytes + (1 << 20) + maxRazorpayTestRequestBytes int64 = 1 << 20 + maxRazorpayLiveRequestBytes int64 = 1 << 20 + robotsTagValue = "noindex, nofollow, noarchive, nosnippet, noimageindex" ) type API struct { @@ -67,6 +70,8 @@ type API struct { Backups *backups.Service RazorpayTest *razorpaytest.Service RazorpayLive *razorpaylive.Service + checkoutMu sync.Mutex + checkoutLimits *checkoutLimiterSet } func New(cfg config.Config, paymentService *payments.Service, smsService *sms.Service, manager *gmessages.Manager) *API { @@ -84,6 +89,14 @@ func (a *API) Register(app core.App) { return event.String(http.StatusOK, "User-agent: *\nContent-Signal: search=no, ai-input=no, ai-train=no, use=immediate\nDisallow:\n") }) e.Router.POST("/api/payments", a.createPayment).Bind(apis.BodyLimit(maxPaymentRequestBytes)) + e.Router.OPTIONS("/api/checkout/v2/{path...}", a.checkoutPreflight) + e.Router.GET("/api/checkout/v2/payment-accounts", a.checkoutPaymentAccounts) + e.Router.POST("/api/checkout/v2/payments", a.checkoutCreatePayment).Bind(apis.BodyLimit(maxPaymentRequestBytes)) + e.Router.GET("/api/checkout/v2/payments/{id}", a.checkoutGetPayment) + e.Router.GET("/api/checkout/v2/razorpay/{mode}/config", a.checkoutRazorpayConfig) + e.Router.POST("/api/checkout/v2/razorpay/{mode}/orders", a.checkoutRazorpayCreateOrder).Bind(apis.BodyLimit(maxRazorpayTestRequestBytes)) + e.Router.GET("/api/checkout/v2/razorpay/{mode}/orders/{id}", a.checkoutRazorpayGetOrder) + e.Router.POST("/api/checkout/v2/razorpay/{mode}/orders/{id}/verify", a.checkoutRazorpayVerify).Bind(apis.BodyLimit(maxRazorpayTestRequestBytes)) e.Router.GET("/api/payment-accounts", a.paymentAccounts) e.Router.GET("/api/payments/{id}", a.getPayment) e.Router.POST("/api/payments/{id}/cancel", a.cancelPayment) @@ -101,6 +114,23 @@ func (a *API) Register(app core.App) { e.Router.GET("/api/paygate/health", a.health) e.Router.GET("/api/config", a.getConfig) e.Router.GET("/api/dashboard", a.dashboard) + e.Router.GET("/api/operator/v2/overview", a.operatorV2Overview) + e.Router.GET("/api/operator/v2/payments", a.operatorV2Payments) + e.Router.GET("/api/operator/v2/payments/{id}", a.operatorV2Payment) + e.Router.PUT("/api/operator/v2/payments/{id}/details", a.operatorV2UpdatePaymentDetails).Bind(apis.BodyLimit(maxOperatorPaymentProfileRequestBytes)) + e.Router.GET("/api/operator/v2/reviews", a.operatorV2Reviews) + e.Router.GET("/api/operator/v2/reviews/{id}", a.operatorV2Review) + e.Router.POST("/api/operator/v2/reviews/{id}/resolve", a.operatorV2ResolveReview).Bind(apis.BodyLimit(maxReviewRequestBytes)) + e.Router.GET("/api/operator/v2/alerts", a.operatorV2Alerts) + e.Router.GET("/api/operator/v2/relay", a.operatorV2Relay) + e.Router.GET("/api/operator/v2/evidence-shadow/google-messages", a.operatorV2GoogleMessagesShadow) + e.Router.GET("/api/operator/v2/reconciliation", a.operatorV2ReconciliationRuns) + e.Router.GET("/api/operator/v2/reconciliation/{id}/entries", a.operatorV2ReconciliationEntries) + e.Router.GET("/api/operator/v2/refunds", a.operatorV2Refunds) + e.Router.GET("/api/operator/v2/records/{kind}", a.operatorV2OperationalRecords) + e.Router.GET("/api/operator/v2/razorpay/{mode}/orders", a.operatorV2RazorpayOrders) + e.Router.POST("/api/operator/v2/payments/{id}/cancel", a.operatorV2CancelPayment).Bind(apis.BodyLimit(maxReviewRequestBytes)) + e.Router.POST("/api/operator/v2/reviews/{id}/dismiss", a.operatorV2DismissReview).Bind(apis.BodyLimit(maxReviewRequestBytes)) e.Router.GET("/api/capacity", a.capacity) e.Router.POST("/api/review-cases/{id}/resolve", a.resolveReview).Bind(apis.BodyLimit(maxReviewRequestBytes)) e.Router.POST("/api/reconciliation/import", a.importReconciliation).Bind(apis.BodyLimit(maxStatementRequestBytes)) @@ -125,11 +155,6 @@ func (a *API) Register(app core.App) { e.Router.GET("/api/connector/gmessages/status", a.gmessagesStatus) e.Router.POST("/api/connector/gmessages/pair/google", a.gmessagesGooglePair).Bind(apis.BodyLimit(maxGMessagesPairBytes)) e.Router.POST("/api/connector/gmessages/reauth/google", a.gmessagesGoogleReauth).Bind(apis.BodyLimit(maxGMessagesPairBytes)) - e.Router.POST("/api/connector/gmessages/pair/qr", a.gmessagesPair) - e.Router.POST("/api/connector/gmessages/pair/qr/refresh", a.gmessagesPairRefresh) - // Backward-compatible QR aliases from the first PayGate rebuild. - e.Router.POST("/api/connector/gmessages/pair", a.gmessagesPair) - e.Router.POST("/api/connector/gmessages/pair/refresh", a.gmessagesPairRefresh) e.Router.POST("/api/connector/gmessages/reconnect", a.gmessagesReconnect) e.Router.DELETE("/api/connector/gmessages/pair", a.gmessagesUnpair) @@ -284,8 +309,8 @@ func (a *API) createPayment(e *core.RequestEvent) error { ExternalID: strings.TrimSpace(body.ExternalID), Metadata: metadata, IdempotencyKey: strings.TrimSpace(e.Request.Header.Get("Idempotency-Key")), - }, func(tx core.App) error { - return a.ensurePaymentAccountReadyInApp(tx, body.PaymentAccount) + }, func(uow store.UnitOfWork) error { + return a.ensurePaymentAccountReadyUoW(uow, body.PaymentAccount) }) if err != nil { return writeDomainError(e, err) @@ -814,17 +839,21 @@ func (a *API) restoreDrill(e *core.RequestEvent) error { return e.JSON(http.StatusOK, result) } -func refundResponse(record *core.Record) map[string]any { - if record == nil { +func refundResponse(refund *domain.Refund) map[string]any { + if refund == nil { return nil } + requestedAt, completedAt := "", "" + if !refund.RequestedAt.IsZero() { + requestedAt = refund.RequestedAt.UTC().Format(time.RFC3339Nano) + } + if !refund.CompletedAt.IsZero() { + completedAt = refund.CompletedAt.UTC().Format(time.RFC3339Nano) + } return map[string]any{ - "id": record.Id, "paymentId": record.GetString("payment"), - "amountPaise": record.GetInt("amount"), "status": record.GetString("status"), - "reason": record.GetString("reason"), "reference": record.GetString("reference"), - "externalId": record.GetString("external_id"), - "requestedAt": record.GetDateTime("requested_at").String(), - "completedAt": record.GetDateTime("completed_at").String(), + "id": refund.ID, "paymentId": refund.PaymentID, "amountPaise": refund.AmountPaise, + "status": refund.Status, "reason": refund.Reason, "reference": refund.Reference, + "externalId": refund.ExternalID, "requestedAt": requestedAt, "completedAt": completedAt, } } @@ -878,34 +907,6 @@ func (a *API) gmessagesGoogleReauth(e *core.RequestEvent) error { return e.JSON(http.StatusOK, a.GMessages.Status()) } -func (a *API) gmessagesPair(e *core.RequestEvent) error { - if !a.dashboardAuth(e) { - return e.UnauthorizedError("dashboard authentication is required", nil) - } - if a.GMessages == nil { - return e.BadRequestError("Google Messages connector is unavailable", nil) - } - qrURL, err := a.GMessages.BeginPair() - if err != nil { - return e.BadRequestError("failed to start Google Messages pairing", err) - } - return e.JSON(http.StatusOK, map[string]any{"qrUrl": qrURL, "status": a.GMessages.Status()}) -} - -func (a *API) gmessagesPairRefresh(e *core.RequestEvent) error { - if !a.dashboardAuth(e) { - return e.UnauthorizedError("dashboard authentication is required", nil) - } - if a.GMessages == nil { - return e.BadRequestError("Google Messages connector is unavailable", nil) - } - qrURL, err := a.GMessages.RefreshPair() - if err != nil { - return e.BadRequestError("failed to refresh Google Messages pairing", err) - } - return e.JSON(http.StatusOK, map[string]any{"qrUrl": qrURL, "status": a.GMessages.Status()}) -} - func (a *API) gmessagesReconnect(e *core.RequestEvent) error { if !a.dashboardAuth(e) { return e.UnauthorizedError("dashboard authentication is required", nil) diff --git a/internal/api/checkout.go b/internal/api/checkout.go new file mode 100644 index 0000000..d3e5e69 --- /dev/null +++ b/internal/api/checkout.go @@ -0,0 +1,233 @@ +package api + +import ( + "encoding/json" + "net/http" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/money" + "github.com/Phloraxx/payment-api/internal/payments" + "github.com/Phloraxx/payment-api/internal/store" + "github.com/pocketbase/pocketbase/core" +) + +var ( + checkoutRequestID = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + checkoutPaymentID = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`) +) + +type checkoutBucket struct { + start time.Time + count int +} + +type checkoutQuota struct { + mu sync.Mutex + perIP map[string]checkoutBucket + global checkoutBucket + perIPLimit, globalLimit int + perIPWindow time.Duration + globalWindow time.Duration +} + +type checkoutLimiterSet struct{ create, status checkoutQuota } + +func newCheckoutLimiterSet() *checkoutLimiterSet { + return &checkoutLimiterSet{ + create: checkoutQuota{perIP: map[string]checkoutBucket{}, perIPLimit: 5, globalLimit: 60, perIPWindow: 5 * time.Minute, globalWindow: time.Minute}, + status: checkoutQuota{perIP: map[string]checkoutBucket{}, perIPLimit: 180, globalLimit: 1800, perIPWindow: time.Minute, globalWindow: time.Minute}, + } +} + +func refreshCheckoutBucket(bucket checkoutBucket, now time.Time, window time.Duration) checkoutBucket { + if bucket.start.IsZero() || now.Sub(bucket.start) >= window { + return checkoutBucket{start: now} + } + return bucket +} + +func checkoutRetryAfter(bucket checkoutBucket, now time.Time, window time.Duration) int { + remaining := window - now.Sub(bucket.start) + seconds := int((remaining + time.Second - 1) / time.Second) + if seconds < 1 { + return 1 + } + return seconds +} + +func (q *checkoutQuota) allow(ip string, now time.Time) (bool, int) { + q.mu.Lock() + defer q.mu.Unlock() + per := refreshCheckoutBucket(q.perIP[ip], now, q.perIPWindow) + global := refreshCheckoutBucket(q.global, now, q.globalWindow) + if per.count >= q.perIPLimit { + q.perIP[ip], q.global = per, global + return false, checkoutRetryAfter(per, now, q.perIPWindow) + } + if global.count >= q.globalLimit { + q.perIP[ip], q.global = per, global + return false, checkoutRetryAfter(global, now, q.globalWindow) + } + per.count++ + global.count++ + q.perIP[ip], q.global = per, global + return true, 0 +} + +func (a *API) checkoutLimiters() *checkoutLimiterSet { + a.checkoutMu.Lock() + defer a.checkoutMu.Unlock() + if a.checkoutLimits == nil { + a.checkoutLimits = newCheckoutLimiterSet() + } + return a.checkoutLimits +} + +func (a *API) checkoutEnabled() bool { return len(a.Config.CheckoutAllowedOrigins) > 0 } + +func (a *API) checkoutOrigin(e *core.RequestEvent) bool { + if !a.checkoutEnabled() { + return false + } + origin := strings.TrimSpace(strings.TrimRight(e.Request.Header.Get("Origin"), "/")) + if origin == "" { + return true + } + for _, allowed := range a.Config.CheckoutAllowedOrigins { + if origin == allowed { + e.Response.Header().Set("Access-Control-Allow-Origin", origin) + e.Response.Header().Set("Vary", "Origin") + return true + } + } + return false +} + +func (a *API) checkoutPreflight(e *core.RequestEvent) error { + if !a.checkoutOrigin(e) { + return e.NotFoundError("route not found", nil) + } + e.Response.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + e.Response.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Idempotency-Key") + e.Response.Header().Set("Access-Control-Max-Age", "600") + return e.NoContent(http.StatusNoContent) +} + +func checkoutError(e *core.RequestEvent, status int, code, message string) error { + return e.JSON(status, map[string]any{"code": code, "message": message}) +} + +func (a *API) checkoutRequireOrigin(e *core.RequestEvent) error { + if !a.checkoutOrigin(e) { + return e.NotFoundError("route not found", nil) + } + e.Response.Header().Set("Cache-Control", "no-store") + return nil +} + +func (a *API) checkoutRateLimit(e *core.RequestEvent, create bool) error { + limits := a.checkoutLimiters() + quota := &limits.status + message := "Too many payment status requests. Please wait and try again." + if create { + quota = &limits.create + message = "Too many payment requests. Please wait and try again." + } + allowed, retry := quota.allow(e.RealIP(), time.Now().UTC()) + if !allowed { + e.Response.Header().Set("Retry-After", strconv.Itoa(retry)) + return checkoutError(e, 429, "RATE_LIMITED", message) + } + return nil +} + +type checkoutCreateBody struct { + Amount json.RawMessage `json:"amount"` + PaymentAccount string `json:"paymentAccount"` +} + +func (a *API) checkoutPaymentAccounts(e *core.RequestEvent) error { + if err := a.checkoutRequireOrigin(e); err != nil { + return err + } + if err := a.checkoutRateLimit(e, false); err != nil { + return err + } + defaultAccount := strings.ToLower(strings.TrimSpace(a.Config.DefaultPaymentAccount)) + if defaultAccount == "" { + defaultAccount = "kotak" + } + accounts, err := a.paymentAccountOptions() + if err != nil { + return checkoutError(e, 502, "PAYGATE_UNAVAILABLE", "Payment service is temporarily unavailable.") + } + return e.JSON(http.StatusOK, map[string]any{"default": defaultAccount, "accounts": accounts}) +} + +func (a *API) checkoutCreatePayment(e *core.RequestEvent) error { + if err := a.checkoutRequireOrigin(e); err != nil { + return err + } + if ct := strings.ToLower(strings.TrimSpace(strings.Split(e.Request.Header.Get("Content-Type"), ";")[0])); ct != "application/json" { + return checkoutError(e, 415, "INVALID_CONTENT_TYPE", "Content-Type must be application/json.") + } + requestID := strings.ToLower(strings.TrimSpace(e.Request.Header.Get("Idempotency-Key"))) + if !checkoutRequestID.MatchString(requestID) { + return checkoutError(e, 400, "INVALID_REQUEST", "requestId must be a UUID.") + } + var body checkoutCreateBody + if err := decodeJSON(e, &body); err != nil { + return checkoutError(e, 400, "INVALID_JSON", "Request body must be valid JSON.") + } + amount, err := money.ParseWholeRupees(body.Amount) + if err != nil { + return checkoutError(e, 400, "INVALID_REQUEST", "Amount must be a positive whole number of rupees.") + } + account := strings.ToLower(strings.TrimSpace(body.PaymentAccount)) + if account != "kotak" && account != "slice" && account != "paytm" { + return checkoutError(e, 400, "INVALID_REQUEST", "paymentAccount must be kotak, slice, or paytm.") + } + if err := a.checkoutRateLimit(e, true); err != nil { + return err + } + payment, replayed, err := a.Payments.CreateGuarded(payments.CreateInput{AmountRupees: amount, PaymentAccount: account, IdempotencyKey: requestID}, func(uow store.UnitOfWork) error { return a.ensurePaymentAccountReadyUoW(uow, account) }) + if err != nil { + return a.checkoutDomainError(e, err) + } + status := http.StatusCreated + if replayed { + status = http.StatusOK + e.Response.Header().Set("X-Idempotent-Replayed", "true") + } + return e.JSON(status, payments.CreateResponse(payment, a.Config)) +} + +func (a *API) checkoutGetPayment(e *core.RequestEvent) error { + if err := a.checkoutRequireOrigin(e); err != nil { + return err + } + id := strings.TrimSpace(e.Request.PathValue("id")) + if !checkoutPaymentID.MatchString(id) { + return checkoutError(e, 400, "INVALID_PAYMENT_ID", "Invalid payment ID.") + } + if err := a.checkoutRateLimit(e, false); err != nil { + return err + } + payment, err := a.Payments.Get(id) + if err != nil { + return a.checkoutDomainError(e, err) + } + return e.JSON(http.StatusOK, payments.PublicPayment(payment)) +} + +func (a *API) checkoutDomainError(e *core.RequestEvent, err error) error { + if de, ok := err.(*domain.Error); ok { + return checkoutError(e, de.Status, de.Code, de.Message) + } + return checkoutError(e, 502, "PAYGATE_UNAVAILABLE", "Payment service is temporarily unavailable.") +} diff --git a/internal/api/checkout_razorpay.go b/internal/api/checkout_razorpay.go new file mode 100644 index 0000000..344b2a5 --- /dev/null +++ b/internal/api/checkout_razorpay.go @@ -0,0 +1,199 @@ +package api + +import ( + "encoding/json" + "net/http" + "regexp" + "strings" + + "github.com/Phloraxx/payment-api/internal/money" + "github.com/Phloraxx/payment-api/internal/razorpaycore" + "github.com/pocketbase/pocketbase/core" +) + +var ( + checkoutRazorpayOrderID = regexp.MustCompile(`^order_[A-Za-z0-9]{6,64}$`) + checkoutRazorpayPaymentID = regexp.MustCompile(`^pay_[A-Za-z0-9_]{6,64}$`) + checkoutRazorpaySignature = regexp.MustCompile(`^[a-fA-F0-9]{64}$`) +) + +type checkoutRazorpayMode struct { + name, disabledCode, keyID, displayName string + enabled bool + service *razorpaycore.Service + livePilot bool +} + +func (a *API) checkoutRazorpayModeFor(name string) (checkoutRazorpayMode, bool) { + switch strings.ToLower(strings.TrimSpace(name)) { + case "test": + return checkoutRazorpayMode{ + name: "test", disabledCode: "RAZORPAY_TEST_DISABLED", + keyID: a.Config.RazorpayTestKeyID, displayName: a.Config.RazorpayTestDisplayName, + enabled: a.Config.RazorpayTestEnabled && a.RazorpayTest != nil, service: a.RazorpayTest, + }, true + case "live": + return checkoutRazorpayMode{ + name: "live", disabledCode: "RAZORPAY_LIVE_DISABLED", + keyID: a.Config.RazorpayLiveKeyID, displayName: a.Config.RazorpayLiveDisplayName, + enabled: a.Config.RazorpayLiveEnabled && a.RazorpayLive != nil, service: a.RazorpayLive, livePilot: true, + }, true + default: + return checkoutRazorpayMode{}, false + } +} + +func (m checkoutRazorpayMode) disabled(e *core.RequestEvent) error { + return checkoutError(e, http.StatusNotFound, m.disabledCode, "Razorpay "+m.name+" mode is disabled.") +} + +func (m checkoutRazorpayMode) orderResponse(record *core.Record) map[string]any { + return razorpaycore.OrderResponse(record, m.keyID, m.displayName) +} +func (a *API) checkoutRazorpayConfig(e *core.RequestEvent) error { + if err := a.checkoutRequireOrigin(e); err != nil { + return err + } + if err := a.checkoutRateLimit(e, false); err != nil { + return err + } + mode, ok := a.checkoutRazorpayModeFor(e.Request.PathValue("mode")) + if !ok { + return e.NotFoundError("route not found", nil) + } + keyID := "" + if mode.enabled { + keyID = mode.keyID + } + return e.JSON(http.StatusOK, map[string]any{ + "enabled": mode.enabled, "keyId": keyID, + "displayName": mode.displayName, "mode": mode.name, + }) +} + +type checkoutRazorpayCreateBody struct { + Amount json.RawMessage `json:"amount"` +} + +func (a *API) checkoutRazorpayCreateOrder(e *core.RequestEvent) error { + if err := a.checkoutRequireOrigin(e); err != nil { + return err + } + mode, ok := a.checkoutRazorpayModeFor(e.Request.PathValue("mode")) + if !ok { + return e.NotFoundError("route not found", nil) + } + if !mode.enabled { + return mode.disabled(e) + } + if ct := strings.ToLower(strings.TrimSpace(strings.Split(e.Request.Header.Get("Content-Type"), ";")[0])); ct != "application/json" { + return checkoutError(e, http.StatusUnsupportedMediaType, "INVALID_CONTENT_TYPE", "Content-Type must be application/json.") + } + requestID := strings.ToLower(strings.TrimSpace(e.Request.Header.Get("Idempotency-Key"))) + if !checkoutRequestID.MatchString(requestID) { + return checkoutError(e, http.StatusBadRequest, "INVALID_REQUEST", "requestId must be a UUID.") + } + var body checkoutRazorpayCreateBody + if err := decodeJSON(e, &body); err != nil { + return checkoutError(e, http.StatusBadRequest, "INVALID_JSON", "Request body must be valid JSON.") + } + rupees, err := money.ParseWholeRupees(body.Amount) + if err != nil { + return checkoutError(e, http.StatusBadRequest, "INVALID_REQUEST", "Amount must be a positive whole number of rupees.") + } + amountPaise, err := money.RupeesToPaise(rupees) + if err != nil || (mode.livePilot && amountPaise != 100) { + message := "Razorpay amount must be between ₹1 and ₹1,00,000." + if mode.livePilot { + message = "Razorpay Live pilot amount must be exactly ₹1." + } + return checkoutError(e, http.StatusBadRequest, "INVALID_REQUEST", message) + } + if err := a.checkoutRateLimit(e, true); err != nil { + return err + } + record, replayed, err := mode.service.Create(e.Request.Context(), razorpaycore.CreateInput{ + AmountPaise: amountPaise, ExternalID: "portal:" + requestID, + IdempotencyKey: requestID, + }) + if err != nil { + return a.checkoutDomainError(e, err) + } + status := http.StatusCreated + if replayed { + status = http.StatusOK + e.Response.Header().Set("X-Idempotent-Replayed", "true") + } + return e.JSON(status, mode.orderResponse(record)) +} + +func (a *API) checkoutRazorpayGetOrder(e *core.RequestEvent) error { + if err := a.checkoutRequireOrigin(e); err != nil { + return err + } + mode, ok := a.checkoutRazorpayModeFor(e.Request.PathValue("mode")) + if !ok { + return e.NotFoundError("route not found", nil) + } + if !mode.enabled { + return mode.disabled(e) + } + id := strings.TrimSpace(e.Request.PathValue("id")) + if !checkoutPaymentID.MatchString(id) { + return checkoutError(e, http.StatusBadRequest, "INVALID_ORDER_ID", "Invalid Razorpay order ID.") + } + if err := a.checkoutRateLimit(e, false); err != nil { + return err + } + record, err := mode.service.Get(id) + if err != nil { + return a.checkoutDomainError(e, err) + } + return e.JSON(http.StatusOK, mode.orderResponse(record)) +} + +type checkoutRazorpayVerifyBody struct { + RazorpayOrderID string `json:"razorpay_order_id"` + RazorpayPaymentID string `json:"razorpay_payment_id"` + RazorpaySignature string `json:"razorpay_signature"` +} + +func (a *API) checkoutRazorpayVerify(e *core.RequestEvent) error { + if err := a.checkoutRequireOrigin(e); err != nil { + return err + } + mode, ok := a.checkoutRazorpayModeFor(e.Request.PathValue("mode")) + if !ok { + return e.NotFoundError("route not found", nil) + } + if !mode.enabled { + return mode.disabled(e) + } + id := strings.TrimSpace(e.Request.PathValue("id")) + if !checkoutPaymentID.MatchString(id) { + return checkoutError(e, http.StatusBadRequest, "INVALID_ORDER_ID", "Invalid Razorpay order ID.") + } + if ct := strings.ToLower(strings.TrimSpace(strings.Split(e.Request.Header.Get("Content-Type"), ";")[0])); ct != "application/json" { + return checkoutError(e, http.StatusUnsupportedMediaType, "INVALID_CONTENT_TYPE", "Content-Type must be application/json.") + } + var body checkoutRazorpayVerifyBody + if err := decodeJSON(e, &body); err != nil { + return checkoutError(e, http.StatusBadRequest, "INVALID_JSON", "Request body must be valid JSON.") + } + if !checkoutRazorpayOrderID.MatchString(body.RazorpayOrderID) || + !checkoutRazorpayPaymentID.MatchString(body.RazorpayPaymentID) || + !checkoutRazorpaySignature.MatchString(body.RazorpaySignature) { + return checkoutError(e, http.StatusBadRequest, "INVALID_REQUEST", "Invalid Razorpay verification response.") + } + if err := a.checkoutRateLimit(e, false); err != nil { + return err + } + record, err := mode.service.Verify(e.Request.Context(), razorpaycore.VerifyInput{ + LocalOrderID: id, RazorpayOrderID: body.RazorpayOrderID, + RazorpayPaymentID: body.RazorpayPaymentID, RazorpaySignature: body.RazorpaySignature, + }) + if err != nil { + return a.checkoutDomainError(e, err) + } + return e.JSON(http.StatusOK, mode.orderResponse(record)) +} diff --git a/internal/api/checkout_test.go b/internal/api/checkout_test.go new file mode 100644 index 0000000..8d74cb3 --- /dev/null +++ b/internal/api/checkout_test.go @@ -0,0 +1,213 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/config" + "github.com/Phloraxx/payment-api/internal/payments" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tests" +) + +const checkoutOrigin = "https://payment.example.com" + +func checkoutHTTPServer(t *testing.T, configure func(*config.Config), before func(*tests.TestApp, *payments.Service)) (*tests.TestApp, *httptest.Server) { + t.Helper() + app := apiTestFactoryWithConfig(t, configure, before) + router, err := apis.NewRouter(app) + if err != nil { + t.Fatal(err) + } + serveEvent := &core.ServeEvent{App: app, Router: router} + if err := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error { return nil }); err != nil { + t.Fatal(err) + } + mux, err := serveEvent.Router.BuildMux() + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + t.Cleanup(app.Cleanup) + return app, server +} + +func checkoutRequest(t *testing.T, server *httptest.Server, method, path, body string, headers map[string]string) (*http.Response, []byte) { + t.Helper() + req, err := http.NewRequest(method, server.URL+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + for key, value := range headers { + req.Header.Set(key, value) + } + res, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + return res, data +} + +func enabledCheckoutConfig(cfg *config.Config) { + cfg.CheckoutAllowedOrigins = []string{checkoutOrigin} + cfg.DefaultPaymentAccount = "kotak" + cfg.KotakUPIID = "operator@kotak" +} + +func TestCheckoutSurfaceDisabledByDefault(t *testing.T) { + _, server := checkoutHTTPServer(t, nil, nil) + res, _ := checkoutRequest(t, server, http.MethodGet, "/api/checkout/v2/payment-accounts", "", map[string]string{"Origin": checkoutOrigin}) + if res.StatusCode != http.StatusNotFound { + t.Fatalf("disabled checkout status=%d; want 404", res.StatusCode) + } +} + +func TestCheckoutCORSAllowlistAndPreflight(t *testing.T) { + _, server := checkoutHTTPServer(t, enabledCheckoutConfig, nil) + res, _ := checkoutRequest(t, server, http.MethodOptions, "/api/checkout/v2/payments", "", map[string]string{"Origin": checkoutOrigin}) + if res.StatusCode != http.StatusNoContent || res.Header.Get("Access-Control-Allow-Origin") != checkoutOrigin { + t.Fatalf("allowed preflight status=%d origin=%q", res.StatusCode, res.Header.Get("Access-Control-Allow-Origin")) + } + res, _ = checkoutRequest(t, server, http.MethodGet, "/api/checkout/v2/payment-accounts", "", map[string]string{"Origin": "https://evil.example"}) + if res.StatusCode != http.StatusNotFound || res.Header.Get("Access-Control-Allow-Origin") != "" { + t.Fatalf("foreign origin status=%d cors=%q", res.StatusCode, res.Header.Get("Access-Control-Allow-Origin")) + } +} +func TestCheckoutCreateReplaysIdempotentlyAndRedactsStatus(t *testing.T) { + _, server := checkoutHTTPServer(t, enabledCheckoutConfig, nil) + headers := map[string]string{ + "Origin": checkoutOrigin, + "Content-Type": "application/json", + "Idempotency-Key": "2f54d1c8-4ef4-4c21-9ff8-b9f4fc8e79a1", + } + body := `{"amount":100,"paymentAccount":"kotak"}` + first, firstBody := checkoutRequest(t, server, http.MethodPost, "/api/checkout/v2/payments", body, headers) + if first.StatusCode != http.StatusCreated { + t.Fatalf("first create status=%d body=%s", first.StatusCode, firstBody) + } + var payment map[string]any + if err := json.Unmarshal(firstBody, &payment); err != nil { + t.Fatal(err) + } + id, _ := payment["id"].(string) + if id == "" { + t.Fatalf("missing payment id: %s", firstBody) + } + replay, replayBody := checkoutRequest(t, server, http.MethodPost, "/api/checkout/v2/payments", body, headers) + if replay.StatusCode != http.StatusOK || replay.Header.Get("X-Idempotent-Replayed") != "true" { + t.Fatalf("replay status=%d replay=%q body=%s", replay.StatusCode, replay.Header.Get("X-Idempotent-Replayed"), replayBody) + } + status, statusBody := checkoutRequest(t, server, http.MethodGet, "/api/checkout/v2/payments/"+id, "", map[string]string{"Origin": checkoutOrigin}) + if status.StatusCode != http.StatusOK { + t.Fatalf("status=%d body=%s", status.StatusCode, statusBody) + } + for _, forbidden := range []string{`"rrn"`, `"payerName"`, `"upiId"`, `"rawSms"`} { + if strings.Contains(string(statusBody), forbidden) { + t.Fatalf("public checkout response exposed %s: %s", forbidden, statusBody) + } + } +} +func TestCheckoutRejectsInvalidRequestsBeforeQuota(t *testing.T) { + _, server := checkoutHTTPServer(t, enabledCheckoutConfig, nil) + headers := map[string]string{ + "Origin": checkoutOrigin, + "Content-Type": "application/json", + "Idempotency-Key": "2f54d1c8-4ef4-4c21-9ff8-b9f4fc8e79a2", + } + cases := []string{ + `{"amount":0,"paymentAccount":"kotak"}`, + `{"amount":100,"paymentAccount":"unknown"}`, + `{"amount":100,"paymentAccount":"kotak","extra":true}`, + } + for _, body := range cases { + res, _ := checkoutRequest(t, server, http.MethodPost, "/api/checkout/v2/payments", body, headers) + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("invalid body %s status=%d", body, res.StatusCode) + } + } + for i := 0; i < 5; i++ { + headers["Idempotency-Key"] = "00000000-0000-4000-8000-00000000000" + string(rune('1'+i)) + res, body := checkoutRequest(t, server, http.MethodPost, "/api/checkout/v2/payments", `{"amount":100,"paymentAccount":"kotak"}`, headers) + if res.StatusCode != http.StatusCreated { + t.Fatalf("valid create #%d status=%d body=%s", i+1, res.StatusCode, body) + } + } +} +func TestCheckoutCreationRateLimitAndRetryAfter(t *testing.T) { + _, server := checkoutHTTPServer(t, enabledCheckoutConfig, nil) + ids := []string{ + "10000000-0000-4000-8000-000000000001", + "10000000-0000-4000-8000-000000000002", + "10000000-0000-4000-8000-000000000003", + "10000000-0000-4000-8000-000000000004", + "10000000-0000-4000-8000-000000000005", + "10000000-0000-4000-8000-000000000006", + } + for i, id := range ids { + res, body := checkoutRequest(t, server, http.MethodPost, "/api/checkout/v2/payments", `{"amount":101,"paymentAccount":"kotak"}`, map[string]string{ + "Origin": checkoutOrigin, "Content-Type": "application/json", "Idempotency-Key": id, + }) + if i < 5 && res.StatusCode != http.StatusCreated { + t.Fatalf("create #%d status=%d body=%s", i+1, res.StatusCode, body) + } + if i == 5 { + if res.StatusCode != http.StatusTooManyRequests || res.Header.Get("Retry-After") == "" || !strings.Contains(string(body), `"code":"RATE_LIMITED"`) { + t.Fatalf("limited status=%d retry=%q body=%s", res.StatusCode, res.Header.Get("Retry-After"), body) + } + } + } +} + +func TestCheckoutGlobalQuotaDoesNotConsumePerIPOnRejection(t *testing.T) { + now := time.Date(2026, 8, 28, 15, 0, 0, 0, time.UTC) + quota := checkoutQuota{perIP: map[string]checkoutBucket{}, perIPLimit: 5, globalLimit: 1, perIPWindow: 5 * time.Minute, globalWindow: time.Minute} + if allowed, _ := quota.allow("198.51.100.1", now); !allowed { + t.Fatal("first global slot should be allowed") + } + if allowed, _ := quota.allow("198.51.100.2", now); allowed { + t.Fatal("second client should be rejected by global quota") + } + if got := quota.perIP["198.51.100.2"].count; got != 0 { + t.Fatalf("global rejection consumed per-IP quota: count=%d", got) + } +} +func TestCheckoutUnavailablePaytmFailsClosed(t *testing.T) { + _, server := checkoutHTTPServer(t, func(cfg *config.Config) { + enabledCheckoutConfig(cfg) + cfg.PaytmUPIID = "merchant@paytm" + }, nil) + res, body := checkoutRequest(t, server, http.MethodPost, "/api/checkout/v2/payments", `{"amount":100,"paymentAccount":"paytm"}`, map[string]string{ + "Origin": checkoutOrigin, + "Content-Type": "application/json", + "Idempotency-Key": "20000000-0000-4000-8000-000000000001", + }) + if res.StatusCode != http.StatusServiceUnavailable || !strings.Contains(string(body), "PAYMENT_ACCOUNT_UNAVAILABLE") { + t.Fatalf("Paytm unavailable status=%d body=%s", res.StatusCode, body) + } +} + +func TestCheckoutInvalidPaymentIDDoesNotConsumeStatusQuota(t *testing.T) { + _, server := checkoutHTTPServer(t, enabledCheckoutConfig, nil) + for i := 0; i < 5; i++ { + res, _ := checkoutRequest(t, server, http.MethodGet, "/api/checkout/v2/payments/!", "", map[string]string{"Origin": checkoutOrigin}) + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("invalid id request #%d status=%d", i+1, res.StatusCode) + } + } + res, body := checkoutRequest(t, server, http.MethodGet, "/api/checkout/v2/payment-accounts", "", map[string]string{"Origin": checkoutOrigin}) + if res.StatusCode != http.StatusOK { + t.Fatalf("valid status request was throttled after invalid IDs: status=%d body=%s", res.StatusCode, body) + } +} diff --git a/internal/api/evidence_shadow_api_test.go b/internal/api/evidence_shadow_api_test.go new file mode 100644 index 0000000..7119cdd --- /dev/null +++ b/internal/api/evidence_shadow_api_test.go @@ -0,0 +1,151 @@ +package api + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/evidenceshadow" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" +) + +func TestGoogleMessagesShadowMetricsAndQRRoutesRetired(t *testing.T) { + app := apiTestFactoryWithGMessages(t) + defer app.Cleanup() + now := time.Now().UTC() + devices, err := app.FindCollectionByNameOrId("relay_devices") + if err != nil { + t.Fatal(err) + } + device := core.NewRecord(devices) + device.Set("device_id", strings.Repeat("a", 64)) + device.Set("name", "Shadow Phone") + device.Set("public_key_pem", "test-key") + device.Set("enabled", true) + if err := app.Save(device); err != nil { + t.Fatal(err) + } + relays, err := app.FindCollectionByNameOrId("relay_events") + if err != nil { + t.Fatal(err) + } + relay := core.NewRecord(relays) + relay.Set("device", device.Id) + relay.Set("event_id", strings.Repeat("b", 64)) + relay.Set("kind", "notification") + relay.Set("app_package", evidenceshadow.GoogleMessagesPackage) + relay.Set("notification_when", now) + relay.Set("processing_status", "shadow_observed") + relay.Set("provider_result", map[string]any{ + "provider": evidenceshadow.Provider, + "parser": "bank_sms_v1", + "parseStatus": "complete", + "amountPaise": 10001, + "referenceHash": evidenceshadow.HashReference("123456789012"), + }) + if err := app.Save(relay); err != nil { + t.Fatal(err) + } + + smsCollection, err := app.FindCollectionByNameOrId("sms_events") + if err != nil { + t.Fatal(err) + } + sms := core.NewRecord(smsCollection) + sms.Set("source", "gmessages") + sms.Set("payment_account", "kotak") + sms.Set("body", "bank credit") + sms.Set("message_time", now) + sms.Set("amount", 10001) + sms.Set("rrn", "123456789012") + sms.Set("processing_status", "matched") + if err := app.Save(sms); err != nil { + t.Fatal(err) + } + + users, err := app.FindCollectionByNameOrId("users") + if err != nil { + t.Fatal(err) + } + operator := core.NewRecord(users) + operator.SetEmail("shadow-operator@example.com") + operator.SetPassword("operator-password-123") + operator.SetVerified(true) + if err := app.Save(operator); err != nil { + t.Fatal(err) + } + token, err := operator.NewAuthToken() + if err != nil { + t.Fatal(err) + } + + router, err := apis.NewRouter(app) + if err != nil { + t.Fatal(err) + } + serveEvent := &core.ServeEvent{App: app, Router: router} + if err := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error { return nil }); err != nil { + t.Fatal(err) + } + mux, err := serveEvent.Router.BuildMux() + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(mux) + defer server.Close() + unauth, err := server.Client().Get(server.URL + "/api/operator/v2/evidence-shadow/google-messages") + if err != nil { + t.Fatal(err) + } + _ = unauth.Body.Close() + if unauth.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauth status=%d", unauth.StatusCode) + } + + req, _ := http.NewRequest(http.MethodGet, server.URL+"/api/operator/v2/evidence-shadow/google-messages?days=14", nil) + req.Header.Set("Authorization", "Bearer "+token) + res, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(res.Body) + _ = res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("metrics status=%d body=%s", res.StatusCode, body) + } + payload := string(body) + for _, want := range []string{`"androidObserved":1`, `"libgmObserved":1`, `"exactMatches":1`, `"removalReady":false`} { + if !strings.Contains(payload, want) { + t.Fatalf("metrics missing %q: %s", want, payload) + } + } + + for _, path := range []string{"/api/connector/gmessages/pair/qr", "/api/connector/gmessages/pair/qr/refresh", "/api/connector/gmessages/pair", "/api/connector/gmessages/pair/refresh"} { + post, _ := http.NewRequest(http.MethodPost, server.URL+path, strings.NewReader(`{}`)) + post.Header.Set("Authorization", "Bearer "+token) + post.Header.Set("Content-Type", "application/json") + postRes, err := server.Client().Do(post) + if err != nil { + t.Fatal(err) + } + _ = postRes.Body.Close() + if postRes.StatusCode != http.StatusNotFound { + t.Fatalf("retired QR route %s status=%d", path, postRes.StatusCode) + } + } + googlePair, _ := http.NewRequest(http.MethodPost, server.URL+"/api/connector/gmessages/pair/google", strings.NewReader(`{"cookieData":""}`)) + googlePair.Header.Set("Authorization", "Bearer "+token) + googlePair.Header.Set("Content-Type", "application/json") + googlePairRes, err := server.Client().Do(googlePair) + if err != nil { + t.Fatal(err) + } + _ = googlePairRes.Body.Close() + if googlePairRes.StatusCode == http.StatusNotFound { + t.Fatalf("Google account pairing route was removed with QR fallback") + } +} diff --git a/internal/api/operator_v2.go b/internal/api/operator_v2.go new file mode 100644 index 0000000..0ec58ed --- /dev/null +++ b/internal/api/operator_v2.go @@ -0,0 +1,384 @@ +package api + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + + "github.com/Phloraxx/payment-api/internal/evidenceshadow" + "github.com/Phloraxx/payment-api/internal/operatoradmin" + "github.com/Phloraxx/payment-api/internal/operatorview" + "github.com/Phloraxx/payment-api/internal/reviews" + "github.com/Phloraxx/payment-api/internal/store" + "github.com/pocketbase/pocketbase/core" +) + +func (a *API) operatorV2Overview(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + view, err := operatorview.New(e.App).Overview(queryLimit(e, 8)) + if err != nil { + return e.InternalServerError("failed to load operator overview", err) + } + payload := map[string]any{"overview": view, "connector": a.connectorStatus()} + if a.Payments != nil { + capacity, capacityErr := a.Payments.Capacity() + if capacityErr != nil { + return e.InternalServerError("failed to load payment capacity", capacityErr) + } + payload["capacity"] = capacity + } + if a.Backups != nil { + backup, backupErr := a.Backups.GetStatus(e.Request.Context(), false) + if backupErr != nil { + payload["backup"] = map[string]any{"enabled": a.Config.BackupCron != "", "error": backupErr.Error()} + } else { + payload["backup"] = backup + } + } + if a.AndroidRelay != nil { + relay, relayErr := a.AndroidRelay.Status(a.Config.AndroidRelayStaleAfter) + if relayErr != nil { + return e.InternalServerError("failed to load relay status", relayErr) + } + payload["relay"] = relay + } + return e.JSON(http.StatusOK, payload) +} +func (a *API) operatorV2Payments(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + query, err := operatorPaymentQuery(e) + if err != nil { + return e.BadRequestError(err.Error(), nil) + } + page, err := operatorview.New(e.App).QueryPayments(query) + if err != nil { + var queryErr *operatorview.PaymentQueryError + if errors.As(err, &queryErr) { + return e.BadRequestError(queryErr.Error(), nil) + } + return e.InternalServerError("failed to list payments", err) + } + return e.JSON(http.StatusOK, page) +} + +func (a *API) operatorV2Payment(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + item, err := operatorview.New(e.App).GetPayment(e.Request.PathValue("id")) + if errors.Is(err, sql.ErrNoRows) { + return e.NotFoundError("payment not found", nil) + } + if err != nil { + return e.InternalServerError("failed to load payment", err) + } + return e.JSON(http.StatusOK, item) +} + +type operatorPaymentDetailsBody struct { + DisplayName string `json:"displayName"` + CustomerName string `json:"customerName"` + CustomerEmail string `json:"customerEmail"` + CustomerPhone string `json:"customerPhone"` + Description string `json:"description"` + AdminNote string `json:"adminNote"` + Tags []string `json:"tags"` + CustomFields map[string]any `json:"customFields"` +} + +func (a *API) operatorV2UpdatePaymentDetails(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + var body operatorPaymentDetailsBody + if err := decodeStrictJSON(e, &body, maxOperatorPaymentProfileRequestBytes); err != nil { + return e.BadRequestError("invalid payment details body: "+err.Error(), nil) + } + service := operatoradmin.Service{Store: store.NewPocketBase(e.App)} + payment, err := service.UpdatePayment(e.Request.Context(), operatoradmin.UpdatePaymentInput{ + PaymentID: e.Request.PathValue("id"), Actor: a.actor(e), + DisplayName: body.DisplayName, CustomerName: body.CustomerName, + CustomerEmail: body.CustomerEmail, CustomerPhone: body.CustomerPhone, Description: body.Description, + AdminNote: body.AdminNote, Tags: body.Tags, CustomFields: body.CustomFields, + }) + if err != nil { + var validationErr *operatoradmin.ValidationError + switch { + case errors.As(err, &validationErr): + return e.BadRequestError(validationErr.Error(), nil) + case errors.Is(err, sql.ErrNoRows): + return e.NotFoundError("payment not found", nil) + default: + return e.InternalServerError("failed to update payment details", err) + } + } + item, err := operatorview.New(e.App).GetPayment(payment.ID) + if err != nil { + return e.InternalServerError("payment updated but operator view could not be loaded", err) + } + return e.JSON(http.StatusOK, item) +} + +func (a *API) operatorV2Reviews(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + items, err := operatorview.New(e.App).ListReviews(e.Request.URL.Query().Get("status"), queryLimit(e, 50)) + if err != nil { + if strings.Contains(err.Error(), "invalid review status") { + return e.BadRequestError("invalid review status", nil) + } + return e.InternalServerError("failed to list reviews", err) + } + return e.JSON(http.StatusOK, map[string]any{"reviews": items}) +} + +func (a *API) operatorV2Alerts(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + items, err := operatorview.New(e.App).ListAlerts(e.Request.URL.Query().Get("status"), queryLimit(e, 50)) + if err != nil { + if strings.Contains(err.Error(), "invalid alert status") { + return e.BadRequestError("invalid alert status", nil) + } + return e.InternalServerError("failed to list alerts", err) + } + return e.JSON(http.StatusOK, map[string]any{"alerts": items}) +} +func (a *API) operatorV2Relay(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + if a.AndroidRelay == nil { + return e.JSON(http.StatusOK, map[string]any{"enabled": false, "ready": false}) + } + status, err := a.AndroidRelay.Status(a.Config.AndroidRelayStaleAfter) + if err != nil { + return e.InternalServerError("failed to load relay status", err) + } + return e.JSON(http.StatusOK, status) +} + +func (a *API) operatorV2GoogleMessagesShadow(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + days := 14 + if raw := strings.TrimSpace(e.Request.URL.Query().Get("days")); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 1 || parsed > 30 { + return e.BadRequestError("days must be an integer between 1 and 30", nil) + } + days = parsed + } + metrics, err := (evidenceshadow.MetricsService{Store: store.NewPocketBase(e.App)}).Current(days) + if err != nil { + return e.InternalServerError("failed to calculate Google Messages shadow parity", err) + } + return e.JSON(http.StatusOK, metrics) +} + +func operatorPaymentQuery(e *core.RequestEvent) (operatorview.PaymentQuery, error) { + values := e.Request.URL.Query() + limit := 25 + if raw := strings.TrimSpace(values.Get("limit")); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 1 || parsed > 100 { + return operatorview.PaymentQuery{}, errors.New("limit must be an integer between 1 and 100") + } + limit = parsed + } + offset := 0 + if raw := strings.TrimSpace(values.Get("offset")); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 0 || parsed > 1_000_000 { + return operatorview.PaymentQuery{}, errors.New("offset must be an integer between 0 and 1000000") + } + offset = parsed + } + return operatorview.PaymentQuery{ + Query: values.Get("q"), Status: values.Get("status"), Account: values.Get("account"), + Sort: values.Get("sort"), Limit: limit, Offset: offset, + }, nil +} + +func decodeStrictJSON(e *core.RequestEvent, dst any, limit int64) error { + raw, err := io.ReadAll(io.LimitReader(e.Request.Body, limit+1)) + if err != nil { + return err + } + if int64(len(raw)) > limit { + return errors.New("JSON body exceeds the allowed size") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(dst); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return errors.New("multiple JSON values are not allowed") + } + return err + } + return nil +} + +func queryLimit(e *core.RequestEvent, fallback int) int { + value := strings.TrimSpace(e.Request.URL.Query().Get("limit")) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return fallback + } + if parsed > 100 { + return 100 + } + return parsed +} + +func (a *API) operatorV2CancelPayment(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + payment, err := a.Payments.Cancel(e.Request.PathValue("id")) + if err != nil { + return writeDomainError(e, err) + } + item, err := operatorview.New(e.App).GetPayment(payment.ID) + if err != nil { + return e.InternalServerError("payment cancelled but operator view could not be loaded", err) + } + return e.JSON(http.StatusOK, item) +} + +type operatorDismissReviewBody struct { + Note string `json:"note"` +} + +func (a *API) operatorV2DismissReview(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + if a.Reviews == nil { + return e.NotFoundError("review service is unavailable", nil) + } + var body operatorDismissReviewBody + if err := decodeJSON(e, &body); err != nil { + return e.BadRequestError("invalid JSON body", err) + } + result, err := a.Reviews.Resolve(reviews.ResolveInput{ + CaseID: e.Request.PathValue("id"), Action: "dismissed", Note: body.Note, Actor: a.actor(e), + }) + if err != nil { + return writeDomainError(e, err) + } + return e.JSON(http.StatusOK, result) +} + +func (a *API) operatorV2Review(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + item, err := operatorview.New(e.App).GetReview(e.Request.PathValue("id")) + if errors.Is(err, sql.ErrNoRows) { + return e.NotFoundError("review case not found", nil) + } + if err != nil { + return e.InternalServerError("failed to load review case", err) + } + return e.JSON(http.StatusOK, item) +} + +func (a *API) operatorV2ResolveReview(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + if a.Reviews == nil { + return e.NotFoundError("review service is unavailable", nil) + } + var body reviewResolutionBody + if err := decodeJSON(e, &body); err != nil { + return e.BadRequestError("invalid JSON body", err) + } + result, err := a.Reviews.Resolve(reviews.ResolveInput{ + CaseID: e.Request.PathValue("id"), Action: body.Action, PaymentID: body.PaymentID, + BankReference: body.BankReference, Note: body.Note, Actor: a.actor(e), + }) + if err != nil { + return writeDomainError(e, err) + } + return e.JSON(http.StatusOK, result) +} + +func (a *API) operatorV2ReconciliationRuns(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + items, err := operatorview.New(e.App).ListReconciliationRuns(queryLimit(e, 50)) + if err != nil { + return e.InternalServerError("failed to list reconciliation runs", err) + } + return e.JSON(http.StatusOK, map[string]any{"runs": items}) +} +func (a *API) operatorV2ReconciliationEntries(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + items, err := operatorview.New(e.App).ListReconciliationEntries(e.Request.PathValue("id"), queryLimit(e, 250)) + if err != nil { + return e.InternalServerError("failed to list reconciliation entries", err) + } + return e.JSON(http.StatusOK, map[string]any{"entries": items}) +} +func (a *API) operatorV2Refunds(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + items, err := operatorview.New(e.App).ListRefunds(queryLimit(e, 50)) + if err != nil { + return e.InternalServerError("failed to list refunds", err) + } + return e.JSON(http.StatusOK, map[string]any{"refunds": items}) +} + +func (a *API) operatorV2OperationalRecords(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + items, err := operatorview.New(e.App).ListOperationalRecords(e.Request.PathValue("kind"), queryLimit(e, 50)) + if err != nil { + if strings.Contains(err.Error(), "invalid operational record kind") { + return e.NotFoundError("record view not found", nil) + } + return e.InternalServerError("failed to list operational records", err) + } + return e.JSON(http.StatusOK, map[string]any{"records": items}) +} + +func (a *API) operatorV2RazorpayOrders(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("operator authentication is required", nil) + } + items, err := operatorview.New(e.App).ListRazorpayOrders(e.Request.PathValue("mode"), queryLimit(e, 50)) + if err != nil { + if strings.Contains(err.Error(), "invalid razorpay mode") { + return e.NotFoundError("razorpay mode not found", nil) + } + return e.InternalServerError("failed to list razorpay orders", err) + } + return e.JSON(http.StatusOK, map[string]any{"orders": items}) +} diff --git a/internal/api/operator_v2_test.go b/internal/api/operator_v2_test.go new file mode 100644 index 0000000..120bee7 --- /dev/null +++ b/internal/api/operator_v2_test.go @@ -0,0 +1,305 @@ +package api + +import ( + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/payments" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tests" +) + +func TestOperatorV2RequiresOperatorAuthAndReturnsTypedViews(t *testing.T) { + var paymentID string + var payablePaise int64 + app := apiTestFactory(t, func(app *tests.TestApp, paymentService *payments.Service) { + payment, _, err := paymentService.Create(payments.CreateInput{AmountRupees: 250, PaymentAccount: "kotak", ExternalID: "ORIGINAL-ORDER", Metadata: map[string]any{"origin": "create"}}) + if err != nil { + t.Fatal(err) + } + paymentID = payment.ID + payablePaise = payment.PayablePaise + record, err := app.FindRecordById("payments", payment.ID) + if err != nil { + t.Fatal(err) + } + record.Set("payer_name", "Sensitive Payer") + record.Set("rrn", "123456789012") + if err := app.Save(record); err != nil { + t.Fatal(err) + } + }) + defer app.Cleanup() + users, err := app.FindCollectionByNameOrId("users") + if err != nil { + t.Fatal(err) + } + operator := core.NewRecord(users) + operator.SetEmail("operator@example.com") + operator.SetPassword("operator-password-123") + operator.SetVerified(true) + if err := app.Save(operator); err != nil { + t.Fatal(err) + } + token, err := operator.NewAuthToken() + if err != nil { + t.Fatal(err) + } + + router, err := apis.NewRouter(app) + if err != nil { + t.Fatal(err) + } + serveEvent := &core.ServeEvent{App: app, Router: router} + if err := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error { return nil }); err != nil { + t.Fatal(err) + } + mux, err := serveEvent.Router.BuildMux() + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(mux) + defer server.Close() + + unauth, err := server.Client().Get(server.URL + "/api/operator/v2/overview") + if err != nil { + t.Fatal(err) + } + _ = unauth.Body.Close() + if unauth.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauth status=%d", unauth.StatusCode) + } + overview := operatorGet(t, server, token, "/api/operator/v2/overview") + for _, want := range []string{`"paymentCounts"`, `"recentPayments"`, paymentID} { + if !strings.Contains(overview, want) { + t.Fatalf("overview missing %q: %s", want, overview) + } + } + if strings.Contains(overview, "Sensitive Payer") || strings.Contains(overview, "123456789012") { + t.Fatalf("overview leaked payment evidence: %s", overview) + } + + list := operatorGet(t, server, token, "/api/operator/v2/payments?status=pending") + if !strings.Contains(list, paymentID) || strings.Contains(list, "Sensitive Payer") || strings.Contains(list, "123456789012") { + t.Fatalf("payment list contract=%s", list) + } + + detail := operatorGet(t, server, token, "/api/operator/v2/payments/"+paymentID) + if !strings.Contains(detail, "Sensitive Payer") || !strings.Contains(detail, "123456789012") { + t.Fatalf("operator detail missing evidence: %s", detail) + } + + paged := operatorGet(t, server, token, "/api/operator/v2/payments?status=pending&limit=1&offset=0") + for _, want := range []string{`"total":1`, `"limit":1`, `"offset":0`} { + if !strings.Contains(paged, want) { + t.Fatalf("paged list missing %q: %s", want, paged) + } + } + + badFilterReq, _ := http.NewRequest(http.MethodGet, server.URL+"/api/operator/v2/payments?account=unknown", nil) + badFilterReq.Header.Set("Authorization", "Bearer "+token) + badFilterRes, err := server.Client().Do(badFilterReq) + if err != nil { + t.Fatal(err) + } + badFilterBody, _ := io.ReadAll(badFilterRes.Body) + _ = badFilterRes.Body.Close() + if badFilterRes.StatusCode != http.StatusBadRequest { + t.Fatalf("bad filter status=%d body=%s", badFilterRes.StatusCode, badFilterBody) + } + + profileBody := `{"displayName":"Workshop registration","customerName":"Sourav P Bijoy","customerEmail":"sourav@example.com","customerPhone":"+91 9000000000","description":"IEEE workshop","adminNote":"private operator note","tags":["event","S7"],"customFields":{"semester":"S7"}}` + unauthPut, _ := http.NewRequest(http.MethodPut, server.URL+"/api/operator/v2/payments/"+paymentID+"/details", strings.NewReader(profileBody)) + unauthPut.Header.Set("Content-Type", "application/json") + unauthPutRes, err := server.Client().Do(unauthPut) + if err != nil { + t.Fatal(err) + } + _ = unauthPutRes.Body.Close() + if unauthPutRes.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauth put status=%d", unauthPutRes.StatusCode) + } + + updated := operatorPut(t, server, token, "/api/operator/v2/payments/"+paymentID+"/details", profileBody) + for _, want := range []string{`"displayName":"Workshop registration"`, `"customerName":"Sourav P Bijoy"`, `"status":"pending"`, `"rrn":"123456789012"`, `"externalId":"ORIGINAL-ORDER"`, `"origin":"create"`} { + if !strings.Contains(updated, want) { + t.Fatalf("updated detail missing %q: %s", want, updated) + } + } + if !strings.Contains(updated, `"payableAmountPaise":`+strconv.FormatInt(payablePaise, 10)) { + t.Fatalf("updated detail changed amount: %s", updated) + } + searched := operatorGet(t, server, token, "/api/operator/v2/payments?q=Sourav&account=kotak&status=pending") + if !strings.Contains(searched, paymentID) || !strings.Contains(searched, `"total":1`) { + t.Fatalf("search contract=%s", searched) + } + + for _, malicious := range []string{`{"status":"paid"}`, `{"payableAmountPaise":1}`, `{"rrn":"000000000000"}`, `{"paymentAccount":"slice"}`, `{"externalId":"tampered"}`, `{"metadata":{"tampered":true}}`} { + req, _ := http.NewRequest(http.MethodPut, server.URL+"/api/operator/v2/payments/"+paymentID+"/details", strings.NewReader(malicious)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + res, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(res.Body) + _ = res.Body.Close() + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("protected update %s status=%d body=%s", malicious, res.StatusCode, body) + } + } + protected, err := app.FindRecordById("payments", paymentID) + if err != nil { + t.Fatal(err) + } + if protected.GetString("status") != "pending" || int64(protected.GetInt("payable_amount")) != payablePaise || protected.GetString("rrn") != "123456789012" || protected.GetString("payment_account") != "kotak" { + t.Fatalf("protected financial fields mutated: status=%q amount=%d rrn=%q account=%q", protected.GetString("status"), protected.GetInt("payable_amount"), protected.GetString("rrn"), protected.GetString("payment_account")) + } + audits, err := app.FindRecordsByFilter("audit_events", "entity_id = {:id} && action = 'payment.profile.updated'", "created", 10, 0, map[string]any{"id": paymentID}) + if err != nil { + t.Fatal(err) + } + if len(audits) != 1 || audits[0].GetString("actor_email") != "operator@example.com" { + t.Fatalf("profile audit=%v", audits) + } + + smsCollection, err := app.FindCollectionByNameOrId("sms_events") + if err != nil { + t.Fatal(err) + } + smsRecord := core.NewRecord(smsCollection) + smsRecord.Set("source", "android_webhook") + smsRecord.Set("payment_account", "kotak") + smsRecord.Set("body", "RAW SECRET SMS BODY MUST NOT LEAK") + smsRecord.Set("message_time", time.Now().UTC()) + smsRecord.Set("amount", 25001) + smsRecord.Set("rrn", "998877665544") + smsRecord.Set("payer_name", "Review Payer") + smsRecord.Set("processing_status", "unmatched") + if err := app.Save(smsRecord); err != nil { + t.Fatal(err) + } + reviewCollection, err := app.FindCollectionByNameOrId("review_cases") + if err != nil { + t.Fatal(err) + } + reviewRecord := core.NewRecord(reviewCollection) + reviewRecord.Set("kind", "unmatched") + reviewRecord.Set("status", "open") + reviewRecord.Set("severity", "warning") + reviewRecord.Set("sms_event", smsRecord.Id) + reviewRecord.Set("reason", "Evidence requires operator review") + reviewRecord.Set("opened_at", time.Now().UTC()) + if err := app.Save(reviewRecord); err != nil { + t.Fatal(err) + } + reviewDetail := operatorGet(t, server, token, "/api/operator/v2/reviews/"+reviewRecord.Id) + for _, want := range []string{`"kind":"sms"`, `"reference":"998877665544"`, `"payerName":"Review Payer"`} { + if !strings.Contains(reviewDetail, want) { + t.Fatalf("review detail missing %q: %s", want, reviewDetail) + } + } + if strings.Contains(reviewDetail, "RAW SECRET SMS BODY MUST NOT LEAK") { + t.Fatalf("review detail leaked raw body: %s", reviewDetail) + } + + badKindReq, _ := http.NewRequest(http.MethodGet, server.URL+"/api/operator/v2/records/not-real", nil) + badKindReq.Header.Set("Authorization", "Bearer "+token) + badKindRes, err := server.Client().Do(badKindReq) + if err != nil { + t.Fatal(err) + } + _ = badKindRes.Body.Close() + if badKindRes.StatusCode != http.StatusNotFound { + t.Fatalf("invalid operational kind status=%d", badKindRes.StatusCode) + } + + unauthCancel, _ := http.NewRequest(http.MethodPost, server.URL+"/api/operator/v2/payments/"+paymentID+"/cancel", strings.NewReader(`{}`)) + unauthCancel.Header.Set("Content-Type", "application/json") + unauthCancelRes, err := server.Client().Do(unauthCancel) + if err != nil { + t.Fatal(err) + } + _ = unauthCancelRes.Body.Close() + if unauthCancelRes.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauth cancel status=%d", unauthCancelRes.StatusCode) + } + cancelled := operatorPost(t, server, token, "/api/operator/v2/payments/"+paymentID+"/cancel", `{}`) + if !strings.Contains(cancelled, `"status":"cancelled"`) { + t.Fatalf("cancel contract=%s", cancelled) + } +} + +func operatorGet(t *testing.T, server *httptest.Server, token, path string) string { + t.Helper() + req, err := http.NewRequest(http.MethodGet, server.URL+path, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + res, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Fatalf("GET %s status=%d body=%s", path, res.StatusCode, body) + } + return string(body) +} + +func operatorPost(t *testing.T, server *httptest.Server, token, path, body string) string { + t.Helper() + req, err := http.NewRequest(http.MethodPost, server.URL+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + res, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + payload, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Fatalf("POST %s status=%d body=%s", path, res.StatusCode, payload) + } + return string(payload) +} + +func operatorPut(t *testing.T, server *httptest.Server, token, path, body string) string { + t.Helper() + req, err := http.NewRequest(http.MethodPut, server.URL+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + res, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + payload, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Fatalf("PUT %s status=%d body=%s", path, res.StatusCode, payload) + } + return string(payload) +} diff --git a/internal/api/payment_accounts.go b/internal/api/payment_accounts.go index 55fc170..3059d0c 100644 --- a/internal/api/payment_accounts.go +++ b/internal/api/payment_accounts.go @@ -1,12 +1,13 @@ package api import ( + "context" "net/http" "strings" "github.com/Phloraxx/payment-api/internal/config" "github.com/Phloraxx/payment-api/internal/domain" - "github.com/pocketbase/pocketbase/core" + "github.com/Phloraxx/payment-api/internal/store" ) type paymentAccountOption struct { @@ -69,7 +70,7 @@ func (a *API) paymentAccountReady(account config.PaymentAccount) (bool, string, return true, "", nil } -func (a *API) paymentAccountReadyInApp(app core.App, account config.PaymentAccount) (bool, string, error) { +func (a *API) paymentAccountReadyUoW(uow store.UnitOfWork, account config.PaymentAccount) (bool, string, error) { if account.ID == "slice" { if a.Config.EmailEvidenceEnabled && a.Email != nil { return true, "", nil @@ -88,7 +89,7 @@ func (a *API) paymentAccountReadyInApp(app core.App, account config.PaymentAccou if a.AndroidRelay == nil { return false, "Paytm is temporarily unavailable. Choose another payment account.", nil } - ready, err := a.AndroidRelay.ReadyInApp(app, a.Config.AndroidRelayStaleAfter) + ready, err := a.AndroidRelay.ReadyUoW(uow, a.Config.AndroidRelayStaleAfter) if err != nil { return false, "", err } @@ -99,8 +100,10 @@ func (a *API) paymentAccountReadyInApp(app core.App, account config.PaymentAccou } func (a *API) ensurePaymentAccountReady(requested string) error { - if a.Payments != nil { - return a.ensurePaymentAccountReadyInApp(a.Payments.App, requested) + if a.Payments != nil && a.Payments.Store != nil { + return a.Payments.Store.View(context.Background(), func(uow store.UnitOfWork) error { + return a.ensurePaymentAccountReadyUoW(uow, requested) + }) } accountID := strings.ToLower(strings.TrimSpace(requested)) if accountID == "" { @@ -126,7 +129,7 @@ func (a *API) ensurePaymentAccountReady(requested string) error { return nil } -func (a *API) ensurePaymentAccountReadyInApp(app core.App, requested string) error { +func (a *API) ensurePaymentAccountReadyUoW(uow store.UnitOfWork, requested string) error { accountID := strings.ToLower(strings.TrimSpace(requested)) if accountID == "" { accountID = strings.ToLower(strings.TrimSpace(a.Config.DefaultPaymentAccount)) @@ -138,7 +141,7 @@ func (a *API) ensurePaymentAccountReadyInApp(app core.App, requested string) err if !ok { return nil // payments.Service returns the canonical invalid/disabled-account error. } - ready, reason, err := a.paymentAccountReadyInApp(app, account) + ready, reason, err := a.paymentAccountReadyUoW(uow, account) if err != nil { return domain.New("PAYMENT_ACCOUNT_UNAVAILABLE", "payment verification is temporarily unavailable", http.StatusServiceUnavailable) } diff --git a/internal/api/payment_accounts_test.go b/internal/api/payment_accounts_test.go index fbd5eec..3bac249 100644 --- a/internal/api/payment_accounts_test.go +++ b/internal/api/payment_accounts_test.go @@ -1,12 +1,15 @@ package api import ( + "context" "testing" "time" "github.com/Phloraxx/payment-api/internal/androidrelay" "github.com/Phloraxx/payment-api/internal/config" + "github.com/Phloraxx/payment-api/internal/domain" "github.com/Phloraxx/payment-api/internal/paymentemail" + "github.com/Phloraxx/payment-api/internal/store" _ "github.com/Phloraxx/payment-api/migrations" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tests" @@ -58,7 +61,15 @@ func TestPaytmReadinessAcceptsHealthyHeartbeat(t *testing.T) { if err := app.Save(device); err != nil { t.Fatal(err) } - if _, err := relay.Heartbeat(device, androidrelay.HeartbeatInput{ + var typedDevice *domain.RelayDevice + if err := relay.Store.View(context.Background(), func(uow store.UnitOfWork) error { + var err error + typedDevice, err = uow.Relay().Get(device.Id) + return err + }); err != nil { + t.Fatal(err) + } + if _, err := relay.Heartbeat(typedDevice, androidrelay.HeartbeatInput{ SchemaVersion: 1, NotificationAccess: true, ListenerConnected: true, }); err != nil { t.Fatal(err) diff --git a/internal/api/razorpay_api_test.go b/internal/api/razorpay_api_test.go index a4b526f..51da60c 100644 --- a/internal/api/razorpay_api_test.go +++ b/internal/api/razorpay_api_test.go @@ -70,6 +70,7 @@ func newRazorpayAPIFixture(t *testing.T, enabled bool) *razorpayAPIFixture { RazorpayTestEnabled: enabled, RazorpayTestKeyID: "rzp_test_api", RazorpayTestKeySecret: "checkout-secret-123456", RazorpayTestWebhookSecret: "webhook-secret-123456789012", RazorpayTestDisplayName: "PayGate Test", + CheckoutAllowedOrigins: []string{checkoutOrigin}, } paymentService := payments.NewService(app, cfg, nil) smsService := sms.NewService(app, paymentService) @@ -245,3 +246,52 @@ func TestRazorpayTestRoutesAcceptServerAPIKey(t *testing.T) { t.Fatalf("get status=%d body=%s", res.StatusCode, body) } } + +func TestCheckoutRazorpayTestCreateAndReplayWithoutAPIKey(t *testing.T) { + fixture := newRazorpayAPIFixture(t, true) + headers := map[string]string{ + "Origin": checkoutOrigin, + "Idempotency-Key": "30000000-0000-4000-8000-000000000001", + } + res, body := fixture.request(t, http.MethodGet, "/api/checkout/v2/razorpay/test/config", "", false, map[string]string{"Origin": checkoutOrigin}) + if res.StatusCode != http.StatusOK || !strings.Contains(body, `"enabled":true`) || !strings.Contains(body, `"keyId":"rzp_test_api"`) { + t.Fatalf("public config status=%d body=%s", res.StatusCode, body) + } + res, body = fixture.request(t, http.MethodPost, "/api/checkout/v2/razorpay/test/orders", `{"amount":2}`, false, headers) + if res.StatusCode != http.StatusCreated || !strings.Contains(body, `"amountPaise":200`) || !strings.Contains(body, `"razorpayOrderId":"order_api_test"`) { + t.Fatalf("public create status=%d body=%s", res.StatusCode, body) + } + if !strings.Contains(body, `"externalId":"portal:30000000-0000-4000-8000-000000000001"`) { + t.Fatalf("public create did not constrain externalId: %s", body) + } + res, replay := fixture.request(t, http.MethodPost, "/api/checkout/v2/razorpay/test/orders", `{"amount":2}`, false, headers) + if res.StatusCode != http.StatusOK || res.Header.Get("X-Idempotent-Replayed") != "true" || jsonStringField(t, replay, "id") != jsonStringField(t, body, "id") { + t.Fatalf("public replay status=%d replay=%q body=%s", res.StatusCode, res.Header.Get("X-Idempotent-Replayed"), replay) + } +} + +func TestCheckoutRazorpayTestVerifyUsesServerOrderAndSignature(t *testing.T) { + fixture := newRazorpayAPIFixture(t, true) + fixture.provider.order = razorpaytest.ProviderOrder{ID: "order_APITest123", Status: "created"} + headers := map[string]string{ + "Origin": checkoutOrigin, + "Idempotency-Key": "30000000-0000-4000-8000-000000000002", + } + res, createBody := fixture.request(t, http.MethodPost, "/api/checkout/v2/razorpay/test/orders", `{"amount":3}`, false, headers) + if res.StatusCode != http.StatusCreated { + t.Fatalf("create status=%d body=%s", res.StatusCode, createBody) + } + localID := jsonStringField(t, createBody, "id") + fixture.provider.payment = razorpaytest.ProviderPayment{OrderID: "order_APITest123", Amount: 300, Currency: "INR", Status: "captured", Method: "netbanking", Captured: true} + bad := `{"razorpay_order_id":"order_APITest123","razorpay_payment_id":"pay_APITest123","razorpay_signature":"` + strings.Repeat("0", 64) + `"}` + res, _ = fixture.request(t, http.MethodPost, "/api/checkout/v2/razorpay/test/orders/"+localID+"/verify", bad, false, map[string]string{"Origin": checkoutOrigin}) + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("tampered public verify status=%d", res.StatusCode) + } + signature := apiCheckoutSignature("checkout-secret-123456", "order_APITest123", "pay_APITest123") + good := `{"razorpay_order_id":"order_APITest123","razorpay_payment_id":"pay_APITest123","razorpay_signature":"` + signature + `"}` + res, body := fixture.request(t, http.MethodPost, "/api/checkout/v2/razorpay/test/orders/"+localID+"/verify", good, false, map[string]string{"Origin": checkoutOrigin}) + if res.StatusCode != http.StatusOK || !strings.Contains(body, `"status":"captured"`) { + t.Fatalf("public verify status=%d body=%s", res.StatusCode, body) + } +} diff --git a/internal/api/razorpay_live_api_test.go b/internal/api/razorpay_live_api_test.go index 7220e1c..095e5b2 100644 --- a/internal/api/razorpay_live_api_test.go +++ b/internal/api/razorpay_live_api_test.go @@ -70,6 +70,7 @@ func newRazorpayLiveAPIFixture(t *testing.T, enabled bool) *razorpayLiveAPIFixtu RazorpayLiveEnabled: enabled, RazorpayLiveKeyID: "rzp_live_api", RazorpayLiveKeySecret: "checkout-secret-123456", RazorpayLiveWebhookSecret: "webhook-secret-123456789012", RazorpayLiveDisplayName: "PayGate Live", + CheckoutAllowedOrigins: []string{checkoutOrigin}, } paymentService := payments.NewService(app, cfg, nil) smsService := sms.NewService(app, paymentService) @@ -253,3 +254,20 @@ func TestRazorpayLiveRoutesAcceptServerAPIKey(t *testing.T) { t.Fatalf("get status=%d body=%s", res.StatusCode, body) } } + +func TestCheckoutRazorpayLiveKeepsOneRupeePilot(t *testing.T) { + fixture := newRazorpayLiveAPIFixture(t, true) + headers := map[string]string{ + "Origin": checkoutOrigin, + "Idempotency-Key": "40000000-0000-4000-8000-000000000001", + } + res, body := fixture.request(t, http.MethodPost, "/api/checkout/v2/razorpay/live/orders", `{"amount":2}`, false, headers) + if res.StatusCode != http.StatusBadRequest || !strings.Contains(body, "exactly ₹1") { + t.Fatalf("live non-pilot amount status=%d body=%s", res.StatusCode, body) + } + headers["Idempotency-Key"] = "40000000-0000-4000-8000-000000000002" + res, body = fixture.request(t, http.MethodPost, "/api/checkout/v2/razorpay/live/orders", `{"amount":1}`, false, headers) + if res.StatusCode != http.StatusCreated || !strings.Contains(body, `"amountPaise":100`) || !strings.Contains(body, `"keyId":"rzp_live_api"`) { + t.Fatalf("live pilot create status=%d body=%s", res.StatusCode, body) + } +} diff --git a/internal/audit/service.go b/internal/audit/service.go index 3483781..2cea646 100644 --- a/internal/audit/service.go +++ b/internal/audit/service.go @@ -6,6 +6,10 @@ import ( "strings" "time" + "context" + + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/store" "github.com/pocketbase/pocketbase/core" ) @@ -25,19 +29,22 @@ type Entry struct { } type Service struct { - App core.App - Now func() time.Time + App core.App // transitional constructor dependency + Store store.Database + Now func() time.Time } func NewService(app core.App) *Service { - return &Service{App: app, Now: time.Now} + return &Service{App: app, Store: store.NewPocketBase(app), Now: time.Now} } func (s *Service) Record(entry Entry) error { - return s.RecordInApp(s.App, entry) + return s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + return s.RecordUoW(uow, entry) + }) } -func (s *Service) RecordInApp(app core.App, entry Entry) error { +func (s *Service) RecordUoW(uow store.UnitOfWork, entry Entry) error { entry.Action = strings.TrimSpace(entry.Action) entry.EntityType = strings.TrimSpace(entry.EntityType) entry.EntityID = strings.TrimSpace(entry.EntityID) @@ -58,22 +65,10 @@ func (s *Service) RecordInApp(app core.App, entry Entry) error { if at.IsZero() { at = s.now() } - collection, err := app.FindCollectionByNameOrId("audit_events") - if err != nil { - return err - } - record := core.NewRecord(collection) - record.Set("action", entry.Action) - record.Set("actor_id", truncate(entry.Actor.ID, 255)) - record.Set("actor_email", truncate(entry.Actor.Email, 255)) - record.Set("entity_type", entry.EntityType) - record.Set("entity_id", entry.EntityID) - record.Set("summary", entry.Summary) - record.Set("occurred_at", at) - if entry.Details != nil { - record.Set("details", entry.Details) - } - return app.Save(record) + return uow.Audit().Record(domain.AuditEvent{ + Action: entry.Action, ActorID: truncate(entry.Actor.ID, 255), ActorEmail: truncate(entry.Actor.Email, 255), + EntityType: entry.EntityType, EntityID: entry.EntityID, Summary: entry.Summary, Details: entry.Details, OccurredAt: at, + }) } func (s *Service) now() time.Time { diff --git a/internal/backups/service.go b/internal/backups/service.go index 60c3abf..d2f66ff 100644 --- a/internal/backups/service.go +++ b/internal/backups/service.go @@ -216,37 +216,51 @@ func (s *Service) RestoreDrill(ctx context.Context) (RestoreDrillResult, error) return RestoreDrillResult{}, fmt.Errorf("extract backup: %w", err) } result := RestoreDrillResult{BackupName: files[0].Name} - err = filepath.WalkDir(destination, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } + filesChecked, err := validateRestoredDatabases(ctx, destination) + if err != nil { + return RestoreDrillResult{}, err + } + result.DatabaseFiles = filesChecked + result.IntegrityChecked = len(filesChecked) + if result.IntegrityChecked == 0 { + return RestoreDrillResult{}, errors.New("restored archive contains no SQLite database") + } + return result, nil +} + +func validateRestoredDatabases(ctx context.Context, destination string) ([]string, error) { + entries, err := os.ReadDir(destination) + if err != nil { + return nil, err + } + files := make([]string, 0, 2) + for _, entry := range entries { + // A PocketBase restore boots only the root database files. Nested .db files + // are retained forensic/safety snapshots (for example quarantine/) and + // must not make an otherwise restorable backup fail its drill. if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".db") { - return nil + continue } - relative, _ := filepath.Rel(destination, path) - result.DatabaseFiles = append(result.DatabaseFiles, relative) + path := filepath.Join(destination, entry.Name()) database, err := sql.Open("sqlite", "file:"+path+"?mode=ro") if err != nil { - return fmt.Errorf("open restored database %s: %w", relative, err) + return nil, fmt.Errorf("open restored database %s: %w", entry.Name(), err) } - defer database.Close() var check string - if err := database.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&check); err != nil { - return fmt.Errorf("integrity check %s: %w", relative, err) + queryErr := database.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&check) + closeErr := database.Close() + if queryErr != nil { + return nil, fmt.Errorf("integrity check %s: %w", entry.Name(), queryErr) + } + if closeErr != nil { + return nil, closeErr } if !strings.EqualFold(strings.TrimSpace(check), "ok") { - return fmt.Errorf("integrity check %s returned %q", relative, check) + return nil, fmt.Errorf("integrity check %s returned %q", entry.Name(), check) } - result.IntegrityChecked++ - return nil - }) - if err != nil { - return RestoreDrillResult{}, err - } - if result.IntegrityChecked == 0 { - return RestoreDrillResult{}, errors.New("restored archive contains no SQLite database") + files = append(files, entry.Name()) } - return result, nil + return files, nil } func (s *Service) download(ctx context.Context, name string) (string, func(), error) { diff --git a/internal/backups/service_test.go b/internal/backups/service_test.go index b7b7182..2f1cb5e 100644 --- a/internal/backups/service_test.go +++ b/internal/backups/service_test.go @@ -2,12 +2,16 @@ package backups import ( "context" + "database/sql" + "os" + "path/filepath" "testing" "time" "github.com/Phloraxx/payment-api/internal/config" _ "github.com/Phloraxx/payment-api/migrations" "github.com/pocketbase/pocketbase/tests" + _ "modernc.org/sqlite" ) func TestConfigureCreateAndVerifyLocalBackup(t *testing.T) { @@ -47,3 +51,34 @@ func TestConfigureCreateAndVerifyLocalBackup(t *testing.T) { t.Fatalf("drill=%+v", drill) } } + +func TestValidateRestoredDatabasesIgnoresNestedForensicSnapshots(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"data.db", "auxiliary.db"} { + db, err := sql.Open("sqlite", filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec("CREATE TABLE sample (id INTEGER PRIMARY KEY, value TEXT)"); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + } + quarantine := filepath.Join(dir, "quarantine", "20260811") + if err := os.MkdirAll(quarantine, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(quarantine, "auxiliary.db"), []byte("not sqlite"), 0o600); err != nil { + t.Fatal(err) + } + + files, err := validateRestoredDatabases(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if len(files) != 2 { + t.Fatalf("validated files=%v", files) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 102094a..d821a26 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,6 +47,7 @@ type Config struct { GMessagesSessionPath string TestMode bool RateLimitsEnabled bool + CheckoutAllowedOrigins []string RetentionEnabled bool SMSRawRetention time.Duration EmailRawRetention time.Duration @@ -221,6 +222,7 @@ func Load() (Config, error) { GMessagesEnabled: gmessagesEnabled, TestMode: testMode, RateLimitsEnabled: rateLimitsEnabled, + CheckoutAllowedOrigins: parseOriginList(os.Getenv("PAYGATE_CHECKOUT_ORIGINS")), RetentionEnabled: retentionEnabled, SMSRawRetention: smsRawRetention, EmailRawRetention: emailRawRetention, @@ -263,6 +265,26 @@ func (c Config) ValidateServe() error { if defaultPaymentAccount == "" { defaultPaymentAccount = "kotak" } + validators := []func() error{ + func() error { return c.validateCore(defaultPaymentAccount) }, + c.validateRelay, + func() error { return c.validatePaymentPolicy(defaultPaymentAccount) }, + c.validateEmailEvidence, + c.validateOutgoingWebhook, + c.validateCheckout, + c.validateOperations, + c.validateRazorpay, + c.validateBackups, + } + for _, validate := range validators { + if err := validate(); err != nil { + return err + } + } + return nil +} + +func (c Config) validateCore(defaultPaymentAccount string) error { if !c.TestMode { var missing []string if c.KotakUPIID == "" && c.UPIID == "" { @@ -287,6 +309,10 @@ func (c Config) ValidateServe() error { if defaultPaymentAccount != "kotak" && defaultPaymentAccount != "slice" && defaultPaymentAccount != "paytm" { return errors.New("PAYMENT_DEFAULT_ACCOUNT must be kotak, slice, or paytm") } + return nil +} + +func (c Config) validateRelay() error { if strings.TrimSpace(c.PaytmNotificationWebhookSecret) != "" && len(c.PaytmNotificationWebhookSecret) < minPrimarySecretLength { return fmt.Errorf("PAYTM_NOTIFICATION_WEBHOOK_SECRET must be at least %d characters when configured", minPrimarySecretLength) } @@ -308,6 +334,10 @@ func (c Config) ValidateServe() error { if strings.TrimSpace(c.PaytmUPIID) == "" && strings.TrimSpace(c.PaytmQRPayload) != "" && len(c.PaytmNotificationWebhookSecret) < minPrimarySecretLength { return errors.New("PAYTM_NOTIFICATION_WEBHOOK_SECRET is required when legacy PAYTM_QR_PAYLOAD is the active Paytm flow") } + return nil +} + +func (c Config) validatePaymentPolicy(defaultPaymentAccount string) error { if _, ok := c.PaymentAccount(defaultPaymentAccount); !ok && !c.TestMode { return fmt.Errorf("%s payment account configuration is required for PAYMENT_DEFAULT_ACCOUNT", strings.ToUpper(defaultPaymentAccount)) } @@ -328,29 +358,58 @@ func (c Config) ValidateServe() error { return fmt.Errorf("WEBHOOK_SECRET must be at least %d characters when LEGACY_SMS_WEBHOOK_ENABLED=true", minPrimarySecretLength) } } - if c.EmailEvidenceEnabled { - if len(c.EmailWebhookSecret) < minPrimarySecretLength { - return fmt.Errorf("PAYMENT_EMAIL_WEBHOOK_SECRET must be at least %d characters when PAYMENT_EMAIL_ENABLED=true", minPrimarySecretLength) - } - address, err := mail.ParseAddress(c.EmailAllowedSender) - if err != nil || !strings.EqualFold(address.Address, c.EmailAllowedSender) || !strings.Contains(address.Address, "@") { - return errors.New("PAYMENT_EMAIL_ALLOWED_SENDER must be one exact email address") - } - if c.EmailAuthServID == "" { - return errors.New("PAYMENT_EMAIL_AUTH_SERV_ID is required when PAYMENT_EMAIL_ENABLED=true") - } - if c.EmailSignatureTolerance <= 0 { - return errors.New("PAYMENT_EMAIL_SIGNATURE_TOLERANCE must be positive") - } + return nil +} + +func (c Config) validateEmailEvidence() error { + if !c.EmailEvidenceEnabled { + return nil } - if c.OutgoingWebhookURL != "" { - if err := validateHTTPURL("OUTGOING_WEBHOOK_URL", c.OutgoingWebhookURL); err != nil { - return err + if len(c.EmailWebhookSecret) < minPrimarySecretLength { + return fmt.Errorf("PAYMENT_EMAIL_WEBHOOK_SECRET must be at least %d characters when PAYMENT_EMAIL_ENABLED=true", minPrimarySecretLength) + } + address, err := mail.ParseAddress(c.EmailAllowedSender) + if err != nil || !strings.EqualFold(address.Address, c.EmailAllowedSender) || !strings.Contains(address.Address, "@") { + return errors.New("PAYMENT_EMAIL_ALLOWED_SENDER must be one exact email address") + } + if c.EmailAuthServID == "" { + return errors.New("PAYMENT_EMAIL_AUTH_SERV_ID is required when PAYMENT_EMAIL_ENABLED=true") + } + if c.EmailSignatureTolerance <= 0 { + return errors.New("PAYMENT_EMAIL_SIGNATURE_TOLERANCE must be positive") + } + return nil +} + +func (c Config) validateOutgoingWebhook() error { + if c.OutgoingWebhookURL == "" { + return nil + } + if err := validateHTTPURL("OUTGOING_WEBHOOK_URL", c.OutgoingWebhookURL); err != nil { + return err + } + if len(c.OutgoingWebhookSecret) < minPrimarySecretLength { + return fmt.Errorf("OUTGOING_WEBHOOK_SECRET must be at least %d characters when OUTGOING_WEBHOOK_URL is configured", minPrimarySecretLength) + } + return nil +} + +func (c Config) validateCheckout() error { + seen := map[string]struct{}{} + for _, origin := range c.CheckoutAllowedOrigins { + parsed, err := url.Parse(origin) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("PAYGATE_CHECKOUT_ORIGINS entries must be https origins without path, query, credentials or fragment: %q", origin) } - if len(c.OutgoingWebhookSecret) < minPrimarySecretLength { - return fmt.Errorf("OUTGOING_WEBHOOK_SECRET must be at least %d characters when OUTGOING_WEBHOOK_URL is configured", minPrimarySecretLength) + if _, ok := seen[origin]; ok { + return fmt.Errorf("PAYGATE_CHECKOUT_ORIGINS contains duplicate origin %q", origin) } + seen[origin] = struct{}{} } + return nil +} + +func (c Config) validateOperations() error { if c.StatementTimezone == "" { return errors.New("STATEMENT_TIMEZONE is required") } @@ -374,6 +433,10 @@ func (c Config) ValidateServe() error { return fmt.Errorf("OPERATOR_ALERT_WEBHOOK_SECRET must be at least %d characters", minPrimarySecretLength) } } + return nil +} + +func (c Config) validateRazorpay() error { if c.RazorpayTestEnabled { if !strings.HasPrefix(c.RazorpayTestKeyID, "rzp_test_") { return errors.New("RAZORPAY_TEST_KEY_ID must be a Test Mode key beginning with rzp_test_") @@ -402,17 +465,19 @@ func (c Config) ValidateServe() error { return errors.New("RAZORPAY_LIVE_DISPLAY_NAME must be between 1 and 128 characters") } } - if c.BackupS3Enabled { - if c.BackupS3Bucket == "" || c.BackupS3Region == "" || c.BackupS3Endpoint == "" || c.BackupS3AccessKey == "" || c.BackupS3Secret == "" { - return errors.New("all PAYGATE_BACKUP_S3_* values are required when S3 backup storage is enabled") - } - if err := validateHTTPURL("PAYGATE_BACKUP_S3_ENDPOINT", c.BackupS3Endpoint); err != nil { - return err - } - } return nil } +func (c Config) validateBackups() error { + if !c.BackupS3Enabled { + return nil + } + if c.BackupS3Bucket == "" || c.BackupS3Region == "" || c.BackupS3Endpoint == "" || c.BackupS3AccessKey == "" || c.BackupS3Secret == "" { + return errors.New("all PAYGATE_BACKUP_S3_* values are required when S3 backup storage is enabled") + } + return validateHTTPURL("PAYGATE_BACKUP_S3_ENDPOINT", c.BackupS3Endpoint) +} + func (c Config) PaymentAccount(id string) (PaymentAccount, bool) { id = strings.ToLower(strings.TrimSpace(id)) switch id { @@ -461,6 +526,18 @@ func validateHTTPURL(name, value string) error { return nil } +func parseOriginList(raw string) []string { + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + value := strings.TrimSpace(strings.TrimRight(part, "/")) + if value != "" { + result = append(result, value) + } + } + return result +} + func env(name, fallback string) string { if v := os.Getenv(name); v != "" { return v diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e022867..5eee5a0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -255,3 +255,29 @@ func TestValidateServeAndroidRelayEnrollmentIsExplicit(t *testing.T) { t.Fatalf("stored pairing secret must be inert when enrollment is disabled: %v", err) } } + +func TestCheckoutOriginsAreOptInAndStrict(t *testing.T) { + base := Config{ + TestMode: true, PaymentTTL: time.Minute, AmountQuarantine: time.Hour, + StatementTimezone: "Asia/Kolkata", BackupMaxKeep: 1, + } + if err := base.ValidateServe(); err != nil { + t.Fatalf("checkout-disabled config rejected: %v", err) + } + base.CheckoutAllowedOrigins = []string{"http://payment.example.com"} + if err := base.ValidateServe(); err == nil || !strings.Contains(err.Error(), "PAYGATE_CHECKOUT_ORIGINS") { + t.Fatalf("non-https checkout origin accepted: %v", err) + } + base.CheckoutAllowedOrigins = []string{"https://payment.example.com/path"} + if err := base.ValidateServe(); err == nil { + t.Fatal("checkout origin with path was accepted") + } + base.CheckoutAllowedOrigins = []string{"https://payment.example.com", "https://payment.example.com"} + if err := base.ValidateServe(); err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("duplicate checkout origin accepted: %v", err) + } + base.CheckoutAllowedOrigins = []string{"https://payment.example.com", "https://pay.ieeesahrdaya.com"} + if err := base.ValidateServe(); err != nil { + t.Fatalf("valid checkout origins rejected: %v", err) + } +} diff --git a/internal/deliveryqueue/queue.go b/internal/deliveryqueue/queue.go new file mode 100644 index 0000000..950f7de --- /dev/null +++ b/internal/deliveryqueue/queue.go @@ -0,0 +1,198 @@ +package deliveryqueue + +import ( + "fmt" + "strings" + "time" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/types" +) + +type Fields struct { + Status string + Attempts string + NextAttemptAt string + LockedAt string + LastAttemptAt string + DeliveredAt string + LastError string + ResponseCode string +} + +type Queue struct { + App core.App + Collection string + Fields Fields + MaxAttempts int + RetryDelays []time.Duration + StaleAfter time.Duration + ExhaustedAfter time.Duration + ErrorMax int + StaleMessage string +} + +type FinishGuard func(*core.Record) bool + +func (q Queue) Due(now time.Time, limit int) ([]*core.Record, error) { + if limit <= 0 { + limit = 50 + } + filter := fmt.Sprintf( + "(%s = 'pending' || %s = 'failed') && %s <= {:now}", + q.Fields.Status, q.Fields.Status, q.Fields.NextAttemptAt, + ) + sort := q.Fields.NextAttemptAt + ",created" + return q.App.FindRecordsByFilter( + q.Collection, filter, sort, limit, 0, + dbx.Params{"now": filterDate(now)}, + ) +} + +func (q Queue) Claim(id string, now time.Time) (*core.Record, error) { + var claimed *core.Record + err := q.App.RunInTransaction(func(tx core.App) error { + record, err := tx.FindRecordById(q.Collection, id) + if err != nil { + return err + } + status := record.GetString(q.Fields.Status) + if status != "pending" && status != "failed" { + return nil + } + if next := record.GetDateTime(q.Fields.NextAttemptAt).Time(); !next.IsZero() && next.After(now) { + return nil + } + record.Set(q.Fields.Status, "sending") + record.Set(q.Fields.LockedAt, now) + record.Set(q.Fields.LastAttemptAt, now) + record.Set(q.Fields.Attempts, record.GetInt(q.Fields.Attempts)+1) + if err := tx.Save(record); err != nil { + return err + } + claimed = record.Clone() + return nil + }) + return claimed, err +} + +func (q Queue) Finish(id string, now time.Time, statusCode int, deliveryErr error, guard FinishGuard) error { + return q.App.RunInTransaction(func(tx core.App) error { + record, err := tx.FindRecordById(q.Collection, id) + if err != nil { + return err + } + if guard != nil && !guard(record) { + return nil + } + record.Set(q.Fields.LockedAt, "") + if q.Fields.ResponseCode != "" { + record.Set(q.Fields.ResponseCode, statusCode) + } + if deliveryErr == nil { + record.Set(q.Fields.Status, "delivered") + record.Set(q.Fields.DeliveredAt, now) + record.Set(q.Fields.LastError, "") + return tx.Save(record) + } + + attempts := record.GetInt(q.Fields.Attempts) + record.Set(q.Fields.LastError, truncate(deliveryErr.Error(), q.errorMax())) + if attempts >= q.maxAttempts() { + record.Set(q.Fields.Status, "exhausted") + record.Set(q.Fields.NextAttemptAt, now.Add(q.exhaustedAfter())) + } else { + record.Set(q.Fields.Status, "failed") + record.Set(q.Fields.NextAttemptAt, now.Add(q.retryDelay(attempts))) + } + return tx.Save(record) + }) +} + +func (q Queue) RecoverStale(now time.Time, limit int) error { + if limit <= 0 { + limit = 50 + } + stale := now.Add(-q.staleAfter()) + filter := fmt.Sprintf("%s = 'sending' && %s < {:stale}", q.Fields.Status, q.Fields.LockedAt) + records, err := q.App.FindRecordsByFilter( + q.Collection, filter, q.Fields.LockedAt, limit, 0, + dbx.Params{"stale": filterDate(stale)}, + ) + if err != nil { + return err + } + for _, record := range records { + record.Set(q.Fields.Status, "failed") + record.Set(q.Fields.LockedAt, "") + record.Set(q.Fields.NextAttemptAt, now) + record.Set(q.Fields.LastError, q.staleMessage()) + if err := q.App.Save(record); err != nil { + return err + } + } + return nil +} + +func (q Queue) retryDelay(attempt int) time.Duration { + if len(q.RetryDelays) == 0 { + return time.Minute + } + index := attempt - 1 + if index < 0 { + index = 0 + } + if index >= len(q.RetryDelays) { + index = len(q.RetryDelays) - 1 + } + return q.RetryDelays[index] +} +func (q Queue) maxAttempts() int { + if q.MaxAttempts > 0 { + return q.MaxAttempts + } + return 1 +} + +func (q Queue) staleAfter() time.Duration { + if q.StaleAfter > 0 { + return q.StaleAfter + } + return 2 * time.Minute +} + +func (q Queue) exhaustedAfter() time.Duration { + if q.ExhaustedAfter > 0 { + return q.ExhaustedAfter + } + return 365 * 24 * time.Hour +} + +func (q Queue) errorMax() int { + if q.ErrorMax > 0 { + return q.ErrorMax + } + return 4096 +} + +func (q Queue) staleMessage() string { + if strings.TrimSpace(q.StaleMessage) != "" { + return strings.TrimSpace(q.StaleMessage) + } + return "recovered stale delivery lease after restart" +} +func filterDate(t time.Time) string { + value, err := types.ParseDateTime(t.UTC()) + if err != nil { + return t.UTC().Format(time.RFC3339Nano) + } + return value.String() +} + +func truncate(value string, max int) string { + if max <= 0 || len(value) <= max { + return value + } + return value[:max] +} diff --git a/internal/deliveryqueue/queue_test.go b/internal/deliveryqueue/queue_test.go new file mode 100644 index 0000000..aef2291 --- /dev/null +++ b/internal/deliveryqueue/queue_test.go @@ -0,0 +1,131 @@ +package deliveryqueue + +import ( + "errors" + "testing" + "time" + + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tests" +) + +func testQueue(t *testing.T) (*tests.TestApp, Queue) { + t.Helper() + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + collection := core.NewBaseCollection("delivery_queue_test") + collection.Fields.Add( + &core.SelectField{Name: "status", Values: []string{"pending", "sending", "delivered", "failed", "exhausted"}, Required: true}, + &core.NumberField{Name: "attempts", OnlyInt: true}, + &core.DateField{Name: "next_attempt_at", Required: true}, + &core.DateField{Name: "locked_at"}, + &core.DateField{Name: "last_attempt_at"}, + &core.DateField{Name: "delivered_at"}, + &core.TextField{Name: "last_error", Max: 4096}, + &core.NumberField{Name: "response_code", OnlyInt: true}, + &core.AutodateField{Name: "created", OnCreate: true}, + ) + if err := app.Save(collection); err != nil { + app.Cleanup() + t.Fatal(err) + } + queue := Queue{ + App: app, Collection: collection.Name, MaxAttempts: 2, + Fields: Fields{ + Status: "status", Attempts: "attempts", NextAttemptAt: "next_attempt_at", + LockedAt: "locked_at", LastAttemptAt: "last_attempt_at", DeliveredAt: "delivered_at", + LastError: "last_error", ResponseCode: "response_code", + }, + RetryDelays: []time.Duration{time.Minute, 5 * time.Minute}, + StaleAfter: time.Minute, ExhaustedAfter: 24 * time.Hour, ErrorMax: 100, + StaleMessage: "stale lease recovered", + } + return app, queue +} + +func newQueueRecord(t *testing.T, app *tests.TestApp, status string, next, locked time.Time) *core.Record { + t.Helper() + collection, err := app.FindCollectionByNameOrId("delivery_queue_test") + if err != nil { + t.Fatal(err) + } + record := core.NewRecord(collection) + record.Set("status", status) + record.Set("next_attempt_at", next) + if !locked.IsZero() { + record.Set("locked_at", locked) + } + if err := app.Save(record); err != nil { + t.Fatal(err) + } + return record +} + +func TestQueueClaimsRetriesAndExhausts(t *testing.T) { + app, queue := testQueue(t) + defer app.Cleanup() + now := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC) + record := newQueueRecord(t, app, "pending", now.Add(-time.Minute), time.Time{}) + + due, err := queue.Due(now, 10) + if err != nil || len(due) != 1 || due[0].Id != record.Id { + t.Fatalf("due=%v err=%v", due, err) + } + claimed, err := queue.Claim(record.Id, now) + if err != nil || claimed == nil || claimed.GetString("status") != "sending" || claimed.GetInt("attempts") != 1 { + t.Fatalf("first claim=%v err=%v", claimed, err) + } + if err := queue.Finish(record.Id, now, 503, errors.New("temporary failure"), nil); err != nil { + t.Fatal(err) + } + failed, _ := app.FindRecordById("delivery_queue_test", record.Id) + if failed.GetString("status") != "failed" || failed.GetInt("response_code") != 503 { + t.Fatalf("first failure status=%s code=%d", failed.GetString("status"), failed.GetInt("response_code")) + } + failed.Set("next_attempt_at", now) + if err := app.Save(failed); err != nil { + t.Fatal(err) + } + claimed, err = queue.Claim(record.Id, now) + if err != nil || claimed == nil || claimed.GetInt("attempts") != 2 { + t.Fatalf("second claim=%v err=%v", claimed, err) + } + if err := queue.Finish(record.Id, now, 500, errors.New("final failure"), nil); err != nil { + t.Fatal(err) + } + exhausted, _ := app.FindRecordById("delivery_queue_test", record.Id) + if exhausted.GetString("status") != "exhausted" { + t.Fatalf("status=%s want exhausted", exhausted.GetString("status")) + } +} + +func TestQueueGuardAndStaleRecovery(t *testing.T) { + app, queue := testQueue(t) + defer app.Cleanup() + now := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC) + record := newQueueRecord(t, app, "pending", now.Add(-time.Minute), time.Time{}) + claimed, err := queue.Claim(record.Id, now) + if err != nil || claimed == nil { + t.Fatalf("claim err=%v", err) + } + if err := queue.Finish(record.Id, now, 204, nil, func(*core.Record) bool { return false }); err != nil { + t.Fatal(err) + } + unchanged, _ := app.FindRecordById("delivery_queue_test", record.Id) + if unchanged.GetString("status") != "sending" { + t.Fatalf("guarded finish changed status to %s", unchanged.GetString("status")) + } + unchanged.Set("locked_at", now.Add(-2*time.Minute)) + if err := app.Save(unchanged); err != nil { + t.Fatal(err) + } + if err := queue.RecoverStale(now, 10); err != nil { + t.Fatal(err) + } + recovered, _ := app.FindRecordById("delivery_queue_test", record.Id) + if recovered.GetString("status") != "failed" || recovered.GetString("last_error") != "stale lease recovered" { + t.Fatalf("recovered status=%s error=%q", recovered.GetString("status"), recovered.GetString("last_error")) + } +} diff --git a/internal/domain/evidence.go b/internal/domain/evidence.go new file mode 100644 index 0000000..92b9d42 --- /dev/null +++ b/internal/domain/evidence.go @@ -0,0 +1,64 @@ +package domain + +import "time" + +type EvidenceSource string +type EvidenceReferenceKind string +type MatchOutcome string + +const ( + EvidenceSourceBankSMS EvidenceSource = "bank_sms" + EvidenceSourceBankEmail EvidenceSource = "bank_email" + EvidenceSourcePaytmNotification EvidenceSource = "paytm_notification" + EvidenceSourceReconciliation EvidenceSource = "reconciliation" + EvidenceSourceManual EvidenceSource = "manual" +) + +const ( + EvidenceReferenceRRN EvidenceReferenceKind = "rrn" + EvidenceReferenceRelay EvidenceReferenceKind = "relay_reference" +) +const ( + MatchMarkedPaid MatchOutcome = "marked_paid" + MatchMarkedLate MatchOutcome = "marked_late" + MatchDuplicateRRN MatchOutcome = "duplicate_rrn" + MatchDuplicateEvidence MatchOutcome = "duplicate_evidence" + MatchRRNAccountMismatch MatchOutcome = "rrn_account_mismatch" + MatchRRNAmountMismatch MatchOutcome = "rrn_amount_mismatch" + MatchEvidenceAccountMismatch MatchOutcome = "evidence_account_mismatch" + MatchEvidenceAmountMismatch MatchOutcome = "evidence_amount_mismatch" + MatchAmbiguous MatchOutcome = "ambiguous" + MatchUnmatched MatchOutcome = "unmatched" + MatchNotMatchable MatchOutcome = "not_matchable" + MatchError MatchOutcome = "error" +) + +type Evidence struct { + Account PaymentAccount + AmountPaise int64 + OccurredFrom time.Time + OccurredUntil time.Time + + Reference string + ReferenceKind EvidenceReferenceKind + Source EvidenceSource + PayerName string + UPIID string +} + +func (e Evidence) NormalizeWindow(now time.Time) Evidence { + now = now.UTC() + e.OccurredFrom = e.OccurredFrom.UTC() + e.OccurredUntil = e.OccurredUntil.UTC() + if e.OccurredFrom.IsZero() || e.OccurredFrom.After(now) { + e.OccurredFrom = now + e.OccurredUntil = now + } + if e.OccurredUntil.IsZero() || e.OccurredUntil.Before(e.OccurredFrom) { + e.OccurredUntil = e.OccurredFrom + } + if e.OccurredUntil.After(now) { + e.OccurredUntil = now + } + return e +} diff --git a/internal/domain/evidence_event.go b/internal/domain/evidence_event.go new file mode 100644 index 0000000..14f0318 --- /dev/null +++ b/internal/domain/evidence_event.go @@ -0,0 +1,82 @@ +package domain + +import "time" + +type SMSEvent struct { + ID string + Source string + SourceEventID string + Sender string + Body string + Account PaymentAccount + MessageTime time.Time + AmountPaise int64 + RRN string + UPIID string + PayerName string + ProcessingStatus string + MatchedPaymentID string + Error string + RawPayload any +} + +type EmailEvent struct { + ID string + Source string + SourceEventID string + EnvelopeSender string + Recipient string + Sender string + Subject string + Body string + Account PaymentAccount + MessageTime time.Time + ReceivedAt time.Time + AuthResult string + AmountPaise int64 + RRN string + UPIID string + PayerName string + ProcessingStatus string + MatchedPaymentID string + Error string + RawPayload any +} + +type ReviewCase struct { + ID string + Kind string + Status string + Severity string + SMSEventID string + EmailEventID string + ReconciliationEntryID string + PaymentID string + CandidatePaymentIDs []string + Reason string + Resolution string + ResolutionNote string + ResolvedBy string + OpenedAt time.Time + ResolvedAt time.Time +} + +type NotificationEvent struct { + ID string + Source string + SourceEventID string + AppPackage string + AppName string + Title string + Body string + BigText string + Channel string + NotificationTime time.Time + Account PaymentAccount + AmountPaise int64 + PayerName string + ProcessingStatus string + MatchedPaymentID string + Error string + RawPayload any +} diff --git a/internal/domain/evidence_shadow.go b/internal/domain/evidence_shadow.go new file mode 100644 index 0000000..64a24bd --- /dev/null +++ b/internal/domain/evidence_shadow.go @@ -0,0 +1,20 @@ +package domain + +import "time" + +type EvidenceShadowMetrics struct { + WindowStart time.Time `json:"windowStart"` + WindowDays int `json:"windowDays"` + AndroidObserved int `json:"androidObserved"` + AndroidParseable int `json:"androidParseable"` + AndroidComplete int `json:"androidComplete"` + LibGMObserved int `json:"libgmObserved"` + LibGMComplete int `json:"libgmComplete"` + ExactMatches int `json:"exactMatches"` + AndroidOnlyComplete int `json:"androidOnlyComplete"` + LibGMOnlyComplete int `json:"libgmOnlyComplete"` + ReferenceCoveragePercent float64 `json:"referenceCoveragePercent"` + ExactParityPercent float64 `json:"exactParityPercent"` + RemovalReady bool `json:"removalReady"` + RemovalGate string `json:"removalGate"` +} diff --git a/internal/domain/operations.go b/internal/domain/operations.go new file mode 100644 index 0000000..e48e937 --- /dev/null +++ b/internal/domain/operations.go @@ -0,0 +1,61 @@ +package domain + +import "time" + +type AuditEvent struct { + Action string + ActorID string + ActorEmail string + EntityType string + EntityID string + Summary string + Details any + OccurredAt time.Time +} + +type ReconciliationEntry struct { + ID string + RunID string + RowNumber int + TransactionTime time.Time + AmountPaise int64 + RRN string + Description string + Status string + PaymentID string + Notes string + RawRow any +} + +type Refund struct { + ID string + PaymentID string + AmountPaise int64 + Status string + Reason string + Reference string + ExternalID string + IdempotencyKey string + Metadata any + RequestedBy string + RequestedAt time.Time + CompletedAt time.Time +} + +type ReconciliationRun struct { + ID string + Filename string + SHA256 string + Status string + CreatedBy string + StartedAt time.Time + CompletedAt time.Time + TotalRows int + MatchedRows int + UnmatchedRows int + DuplicateRows int + ConflictRows int + InvalidRows int + Error string + Summary any +} diff --git a/internal/domain/payment.go b/internal/domain/payment.go index 44cff60..e1312e5 100644 --- a/internal/domain/payment.go +++ b/internal/domain/payment.go @@ -35,6 +35,7 @@ type Payment struct { RequestedPaise int64 PayablePaise int64 Status PaymentStatus + CreatedAt time.Time ExpiresAt time.Time ReuseAfter time.Time RRN string @@ -46,4 +47,13 @@ type Payment struct { ResolvedAt time.Time ExternalID string IdempotencyKey string + Metadata any + DisplayName string + CustomerName string + CustomerEmail string + CustomerPhone string + Description string + AdminNote string + Tags []string + CustomFields any } diff --git a/internal/domain/relay_health.go b/internal/domain/relay_health.go new file mode 100644 index 0000000..20ac867 --- /dev/null +++ b/internal/domain/relay_health.go @@ -0,0 +1,162 @@ +package domain + +import ( + "fmt" + "strings" + "time" +) + +type RelayDeviceHealth struct { + Enabled bool + AppVersion string + LastSeenAt time.Time + LastHeartbeatAt time.Time + HeartbeatGraceUntil time.Time + NotificationAccess bool + ListenerConnected bool + PowerHealthReported bool + BatteryOptimizationExempt bool + BackgroundRestricted bool + ForegroundServiceActive bool +} + +func (h RelayDeviceHealth) LegacyGraceActive(now time.Time) bool { + return h.Enabled && h.LastHeartbeatAt.IsZero() && !h.HeartbeatGraceUntil.IsZero() && now.Before(h.HeartbeatGraceUntil) +} + +// PowerTelemetryGraceActive bridges a server upgrade where the previous API +// accepted signed heartbeats but did not persist the newer power-health fields. +// It is deliberately bounded by heartbeat_grace_until and still requires a +// fresh heartbeat plus working notification-listener access. +func (h RelayDeviceHealth) PowerTelemetryGraceActive(now time.Time, staleAfter time.Duration) bool { + if !h.Enabled || h.PowerHealthReported || h.LastHeartbeatAt.IsZero() || h.HeartbeatGraceUntil.IsZero() || !now.Before(h.HeartbeatGraceUntil) { + return false + } + if staleAfter <= 0 { + staleAfter = time.Hour + } + if h.LastSeenAt.IsZero() || h.LastSeenAt.Before(now.Add(-staleAfter)) { + return false + } + return h.NotificationAccess && h.ListenerConnected +} + +func (h RelayDeviceHealth) PowerReady() bool { + if !relayPowerHealthRequired(h.AppVersion) { + return true + } + return h.PowerHealthReported && h.BatteryOptimizationExempt && !h.BackgroundRestricted && h.ForegroundServiceActive +} + +func (h RelayDeviceHealth) CurrentReady(now time.Time, staleAfter time.Duration) bool { + if !h.Enabled || h.LastHeartbeatAt.IsZero() { + return false + } + if staleAfter <= 0 { + staleAfter = time.Hour + } + if h.LastSeenAt.IsZero() || h.LastSeenAt.Before(now.Add(-staleAfter)) { + return false + } + return h.NotificationAccess && h.ListenerConnected && h.PowerReady() +} + +func (h RelayDeviceHealth) Ready(now time.Time, staleAfter time.Duration) bool { + return h.LegacyGraceActive(now) || h.PowerTelemetryGraceActive(now, staleAfter) || h.CurrentReady(now, staleAfter) +} +func relayPowerHealthRequired(version string) bool { + version = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(version), "v")) + version = strings.SplitN(version, "-", 2)[0] + parts := strings.Split(version, ".") + if len(parts) < 3 { + return false + } + major, minor, patch := 0, 0, 0 + if _, err := fmt.Sscanf(parts[0]+"."+parts[1]+"."+parts[2], "%d.%d.%d", &major, &minor, &patch); err != nil { + return false + } + if major != 0 { + return major > 0 + } + if minor != 3 { + return minor > 3 + } + return patch >= 1 +} + +type RelayDevice struct { + ID string + DeviceID string + Name string + PublicKeyPEM string + Enabled bool + AppVersion string + AndroidVersion string + DeviceModel string + EnrolledAt time.Time + LastSeenAt time.Time + LastHeartbeatAt time.Time + HeartbeatGraceUntil time.Time + NotificationAccess bool + ListenerConnected bool + PowerHealthReported bool + BatteryOptimizationExempt bool + PowerSaveMode bool + BackgroundRestricted bool + ForegroundServiceActive bool + PendingCount int + FailedCount int + LastClientError string + LastClientDeliveryAt time.Time + CreatedAt time.Time +} + +func (d RelayDevice) Health() RelayDeviceHealth { + return RelayDeviceHealth{ + Enabled: d.Enabled, AppVersion: d.AppVersion, LastSeenAt: d.LastSeenAt, + LastHeartbeatAt: d.LastHeartbeatAt, HeartbeatGraceUntil: d.HeartbeatGraceUntil, + NotificationAccess: d.NotificationAccess, ListenerConnected: d.ListenerConnected, + PowerHealthReported: d.PowerHealthReported, BatteryOptimizationExempt: d.BatteryOptimizationExempt, + BackgroundRestricted: d.BackgroundRestricted, ForegroundServiceActive: d.ForegroundServiceActive, + } +} + +type RelayEvent struct { + ID string + DeviceRecordID string + EventID string + Kind string + AppPackage string + AppName string + NotificationKey string + NotificationID int + NotificationTag string + GroupKey string + IsGroupSummary bool + PostTime time.Time + NotificationWhen time.Time + CapturedAt time.Time + ChannelID string + Category string + Title string + Body string + BigText string + SubText string + SummaryText string + TextLines []string + CustomTexts []string + ProcessingStatus string + DownstreamEventID string + MatchedPaymentID string + ProviderResult any + Error string + RawPayload any + CreatedAt time.Time +} + +type RelayEventStats struct { + LastEventAt time.Time + LastMatchedAt time.Time + LastMatchedPaymentID string + RecentErrorCount int64 +} diff --git a/internal/domain/relay_health_test.go b/internal/domain/relay_health_test.go new file mode 100644 index 0000000..47f141d --- /dev/null +++ b/internal/domain/relay_health_test.go @@ -0,0 +1,64 @@ +package domain + +import ( + "testing" + "time" +) + +func TestRelayDeviceHealthRequiresPowerStateFromV031(t *testing.T) { + now := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC) + base := RelayDeviceHealth{ + Enabled: true, AppVersion: "0.3.1", LastSeenAt: now, + LastHeartbeatAt: now, NotificationAccess: true, ListenerConnected: true, + PowerHealthReported: true, BatteryOptimizationExempt: true, ForegroundServiceActive: true, + } + if !base.Ready(now, time.Hour) { + t.Fatal("healthy v0.3.1 relay should be ready") + } + base.BatteryOptimizationExempt = false + if base.Ready(now, time.Hour) { + t.Fatal("battery-optimized v0.3.1 relay must fail closed") + } + base.AppVersion = "0.3.0" + if !base.Ready(now, time.Hour) { + t.Fatal("v0.3.0 compatibility should not require power-health fields") + } +} +func TestRelayDeviceHealthGraceAndStaleness(t *testing.T) { + now := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC) + grace := RelayDeviceHealth{Enabled: true, HeartbeatGraceUntil: now.Add(time.Minute)} + if !grace.Ready(now, time.Hour) { + t.Fatal("bounded legacy grace should remain ready") + } + if grace.Ready(now.Add(2*time.Minute), time.Hour) { + t.Fatal("expired legacy grace must fail closed") + } + current := RelayDeviceHealth{ + Enabled: true, AppVersion: "0.3.1", LastHeartbeatAt: now.Add(-2 * time.Hour), LastSeenAt: now.Add(-2 * time.Hour), + NotificationAccess: true, ListenerConnected: true, PowerHealthReported: true, + BatteryOptimizationExempt: true, ForegroundServiceActive: true, + } + if current.Ready(now, time.Hour) { + t.Fatal("stale relay must not be ready") + } +} + +func TestRelayDeviceHealthPowerTelemetryCutoverGrace(t *testing.T) { + now := time.Date(2026, 8, 29, 13, 30, 0, 0, time.UTC) + health := RelayDeviceHealth{ + Enabled: true, AppVersion: "0.3.1", LastHeartbeatAt: now.Add(-time.Minute), + LastSeenAt: now.Add(-time.Minute), HeartbeatGraceUntil: now.Add(2 * time.Hour), + NotificationAccess: true, ListenerConnected: true, + } + if !health.Ready(now, time.Hour) { + t.Fatal("fresh pre-power-telemetry heartbeat should receive bounded cutover grace") + } + health.NotificationAccess = false + if health.Ready(now, time.Hour) { + t.Fatal("cutover grace must still require notification access") + } + health.NotificationAccess = true + if health.Ready(now.Add(2*time.Hour+time.Second), time.Hour) { + t.Fatal("expired power telemetry grace must fail closed") + } +} diff --git a/internal/evidenceshadow/metrics.go b/internal/evidenceshadow/metrics.go new file mode 100644 index 0000000..bed8080 --- /dev/null +++ b/internal/evidenceshadow/metrics.go @@ -0,0 +1,150 @@ +package evidenceshadow + +import ( + "context" + "encoding/json" + "fmt" + "math" + "sort" + "time" + + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/store" +) + +const GoogleMessagesPackage = "com.google.android.apps.messaging" +const correlationWindow = 5 * time.Minute + +type MetricsService struct { + Store store.Database + Now func() time.Time +} + +func (s MetricsService) Current(days int) (domain.EvidenceShadowMetrics, error) { + if days <= 0 { + days = 14 + } + if days > 30 { + days = 30 + } + now := time.Now().UTC() + if s.Now != nil { + now = s.Now().UTC() + } + since := now.Add(-time.Duration(days) * 24 * time.Hour) + metrics := domain.EvidenceShadowMetrics{WindowStart: since, WindowDays: days, RemovalGate: "collect_more"} + var android []*domain.RelayEvent + var libgm []*domain.SMSEvent + err := s.Store.View(context.Background(), func(uow store.UnitOfWork) error { + var err error + android, err = uow.RelayEvents().ListByPackageSince(GoogleMessagesPackage, since, 5000) + if err != nil { + return err + } + libgm, err = uow.SMSEvents().ListBySourceSince("gmessages", since, 5000) + return err + }) + if err != nil { + return metrics, err + } + return calculate(metrics, android, libgm), nil +} + +type comparable struct { + amount int64 + hash string + at time.Time +} + +func calculate(metrics domain.EvidenceShadowMetrics, android []*domain.RelayEvent, libgm []*domain.SMSEvent) domain.EvidenceShadowMetrics { + metrics.AndroidObserved = len(android) + metrics.LibGMObserved = len(libgm) + androidComplete := make([]comparable, 0, len(android)) + for _, event := range android { + annotation, ok := annotationFrom(event.ProviderResult) + if !ok || annotation.Provider != Provider { + continue + } + if annotation.AmountPaise > 0 { + metrics.AndroidParseable++ + } + if annotation.ParseStatus == "complete" && annotation.AmountPaise > 0 && annotation.ReferenceHash != "" { + metrics.AndroidComplete++ + at := event.NotificationWhen + if at.IsZero() { + at = event.CreatedAt + } + androidComplete = append(androidComplete, comparable{amount: annotation.AmountPaise, hash: annotation.ReferenceHash, at: at}) + } + } + libComplete := make([]comparable, 0, len(libgm)) + for _, event := range libgm { + if event.AmountPaise <= 0 || event.RRN == "" { + continue + } + metrics.LibGMComplete++ + libComplete = append(libComplete, comparable{amount: event.AmountPaise, hash: HashReference(event.RRN), at: event.MessageTime}) + } + metrics.ExactMatches = correlate(androidComplete, libComplete) + metrics.AndroidOnlyComplete = metrics.AndroidComplete - metrics.ExactMatches + metrics.LibGMOnlyComplete = metrics.LibGMComplete - metrics.ExactMatches + metrics.ReferenceCoveragePercent = percent(metrics.AndroidComplete, metrics.AndroidParseable) + metrics.ExactParityPercent = percent(metrics.ExactMatches, metrics.LibGMComplete) + metrics.RemovalReady = metrics.LibGMComplete >= 100 && metrics.AndroidParseable >= 100 && metrics.LibGMOnlyComplete == 0 && metrics.ReferenceCoveragePercent == 100 && metrics.ExactParityPercent == 100 + if metrics.RemovalReady { + metrics.RemovalGate = "eligible_for_manual_removal_review" + } else if metrics.LibGMComplete >= 100 || metrics.AndroidParseable >= 100 { + metrics.RemovalGate = "keep_libgm_parity_incomplete" + } + return metrics +} + +func annotationFrom(value any) (Annotation, bool) { + data, err := json.Marshal(value) + if err != nil { + return Annotation{}, false + } + var result Annotation + if json.Unmarshal(data, &result) != nil { + return Annotation{}, false + } + return result, result.Provider != "" +} + +func correlate(android, libgm []comparable) int { + sort.Slice(android, func(i, j int) bool { return android[i].at.Before(android[j].at) }) + sort.Slice(libgm, func(i, j int) bool { return libgm[i].at.Before(libgm[j].at) }) + used := make([]bool, len(android)) + matches := 0 + for _, bank := range libgm { + best, bestDelta := -1, time.Duration(math.MaxInt64) + for i, relay := range android { + if used[i] || bank.amount != relay.amount || bank.hash != relay.hash { + continue + } + delta := bank.at.Sub(relay.at) + if delta < 0 { + delta = -delta + } + if delta <= correlationWindow && delta < bestDelta { + best, bestDelta = i, delta + } + } + if best >= 0 { + used[best] = true + matches++ + } + } + return matches +} + +func percent(numerator, denominator int) float64 { + if denominator == 0 { + return 0 + } + return math.Round((float64(numerator)/float64(denominator))*10000) / 100 +} + +func ExplainGate(metrics domain.EvidenceShadowMetrics) string { + return fmt.Sprintf("%s: %d exact pairs, %d libgm-only complete events, %.2f%% Android reference coverage over %d days", metrics.RemovalGate, metrics.ExactMatches, metrics.LibGMOnlyComplete, metrics.ReferenceCoveragePercent, metrics.WindowDays) +} diff --git a/internal/evidenceshadow/shadow.go b/internal/evidenceshadow/shadow.go new file mode 100644 index 0000000..75b8485 --- /dev/null +++ b/internal/evidenceshadow/shadow.go @@ -0,0 +1,53 @@ +package evidenceshadow + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/sms" +) + +const Provider = "google_messages_android_shadow" + +type Annotation struct { + Provider string `json:"provider"` + Parser string `json:"parser"` + ParseStatus string `json:"parseStatus"` + AmountPaise int64 `json:"amountPaise,omitempty"` + ReferenceHash string `json:"referenceHash,omitempty"` +} + +func Annotate(event *domain.RelayEvent, text string) Annotation { + result := Annotation{Provider: Provider, Parser: "bank_sms_v1", ParseStatus: "unrecognized"} + parsed, err := sms.Parse(strings.TrimSpace(text)) + if errors.Is(err, sms.ErrUnrecognized) { + event.ProviderResult = result + return result + } + if err != nil { + result.ParseStatus = "parse_error" + event.ProviderResult = result + return result + } + result.AmountPaise = parsed.AmountPaise + if strings.TrimSpace(parsed.RRN) == "" { + result.ParseStatus = "amount_only" + } else { + result.ParseStatus = "complete" + result.ReferenceHash = HashReference(parsed.RRN) + } + event.ProviderResult = result + return result +} + +func HashReference(reference string) string { + normalized := strings.ToUpper(strings.TrimSpace(reference)) + if normalized == "" { + return "" + } + sum := sha256.Sum256([]byte(normalized)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/evidenceshadow/shadow_test.go b/internal/evidenceshadow/shadow_test.go new file mode 100644 index 0000000..f134aa1 --- /dev/null +++ b/internal/evidenceshadow/shadow_test.go @@ -0,0 +1,43 @@ +package evidenceshadow + +import ( + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/domain" +) + +func TestAnnotateStoresOnlyHashedReferenceMetadata(t *testing.T) { + event := &domain.RelayEvent{} + annotation := Annotate(event, "Received Rs.100.01 from Person UPI Ref:123456789012") + if annotation.ParseStatus != "complete" || annotation.AmountPaise != 10001 { + t.Fatalf("annotation=%+v", annotation) + } + if annotation.ReferenceHash == "" || annotation.ReferenceHash == "123456789012" { + t.Fatalf("reference was not irreversibly represented: %+v", annotation) + } + if event.ProviderResult != annotation { + t.Fatalf("provider result=%+v", event.ProviderResult) + } +} + +func TestCalculateRequiresCompleteExactParityForRemovalReview(t *testing.T) { + now := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC) + android := make([]*domain.RelayEvent, 0, 100) + libgm := make([]*domain.SMSEvent, 0, 100) + for i := 0; i < 100; i++ { + ref := "REF" + string(rune('A'+i%26)) + string(rune('A'+(i/26)%26)) + "12345678" + at := now.Add(time.Duration(i) * time.Minute) + android = append(android, &domain.RelayEvent{NotificationWhen: at, ProviderResult: Annotation{Provider: Provider, Parser: "bank_sms_v1", ParseStatus: "complete", AmountPaise: 10001 + int64(i), ReferenceHash: HashReference(ref)}}) + libgm = append(libgm, &domain.SMSEvent{MessageTime: at.Add(10 * time.Second), AmountPaise: 10001 + int64(i), RRN: ref}) + } + metrics := calculate(domain.EvidenceShadowMetrics{WindowDays: 14}, android, libgm) + if !metrics.RemovalReady || metrics.ExactMatches != 100 || metrics.ExactParityPercent != 100 || metrics.ReferenceCoveragePercent != 100 { + t.Fatalf("metrics=%+v", metrics) + } + android[0].ProviderResult = Annotation{Provider: Provider, Parser: "bank_sms_v1", ParseStatus: "amount_only", AmountPaise: 10001} + metrics = calculate(domain.EvidenceShadowMetrics{WindowDays: 14}, android, libgm) + if metrics.RemovalReady || metrics.LibGMOnlyComplete != 1 || metrics.ReferenceCoveragePercent >= 100 { + t.Fatalf("incomplete metrics=%+v", metrics) + } +} diff --git a/internal/operatoradmin/service.go b/internal/operatoradmin/service.go new file mode 100644 index 0000000..37fc86d --- /dev/null +++ b/internal/operatoradmin/service.go @@ -0,0 +1,261 @@ +package operatoradmin + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/mail" + "strings" + "time" + + "github.com/Phloraxx/payment-api/internal/audit" + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/store" +) + +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + if e.Field == "" { + return e.Message + } + return e.Field + ": " + e.Message +} + +type UpdatePaymentInput struct { + PaymentID string + Actor audit.Actor + DisplayName string + CustomerName string + CustomerEmail string + CustomerPhone string + Description string + AdminNote string + Tags []string + CustomFields map[string]any +} + +type Service struct { + Store store.Database + Now func() time.Time +} + +func (s *Service) UpdatePayment(ctx context.Context, input UpdatePaymentInput) (*domain.Payment, error) { + if s == nil || s.Store == nil { + return nil, fmt.Errorf("operator admin store is required") + } + normalized, err := normalize(input) + if err != nil { + return nil, err + } + now := time.Now().UTC() + if s.Now != nil { + now = s.Now().UTC() + } + var updated *domain.Payment + err = s.Store.Write(ctx, func(tx store.UnitOfWork) error { + payment, err := tx.Payments().Get(normalized.PaymentID) + if err != nil { + return err + } + before := profileAuditSnapshot(payment) + changed := applyProfile(payment, normalized) + if len(changed) == 0 { + updated = payment + return nil + } + if err := tx.Payments().Save(payment); err != nil { + return err + } + after := profileAuditSnapshot(payment) + auditService := audit.Service{Now: s.Now} + if err := auditService.RecordUoW(tx, audit.Entry{ + Action: "payment.profile.updated", Actor: normalized.Actor, + EntityType: "payment", EntityID: payment.ID, + Summary: "Updated payment business details", + Details: map[string]any{"fields": changed, "before": before, "after": after}, OccurredAt: now, + }); err != nil { + return err + } + updated = payment + return nil + }) + if err != nil { + return nil, err + } + return updated, nil +} + +func normalize(input UpdatePaymentInput) (UpdatePaymentInput, error) { + input.PaymentID = strings.TrimSpace(input.PaymentID) + if input.PaymentID == "" { + return input, invalid("paymentId", "is required") + } + input.DisplayName = strings.TrimSpace(input.DisplayName) + input.CustomerName = strings.TrimSpace(input.CustomerName) + input.CustomerEmail = strings.TrimSpace(input.CustomerEmail) + input.CustomerPhone = strings.TrimSpace(input.CustomerPhone) + input.Description = strings.TrimSpace(input.Description) + input.AdminNote = strings.TrimSpace(input.AdminNote) + for field, value := range map[string]string{ + "displayName": input.DisplayName, "customerName": input.CustomerName, + "customerPhone": input.CustomerPhone, + } { + max := 255 + if field == "customerPhone" { + max = 64 + } + if runeLen(value) > max { + return input, invalid(field, fmt.Sprintf("must be at most %d characters", max)) + } + } + if runeLen(input.CustomerEmail) > 254 { + return input, invalid("customerEmail", "must be at most 254 characters") + } + if input.CustomerEmail != "" { + address, err := mail.ParseAddress(input.CustomerEmail) + if err != nil || !strings.EqualFold(address.Address, input.CustomerEmail) { + return input, invalid("customerEmail", "must be a valid email address") + } + } + if runeLen(input.Description) > 4096 { + return input, invalid("description", "must be at most 4096 characters") + } + if runeLen(input.AdminNote) > 4096 { + return input, invalid("adminNote", "must be at most 4096 characters") + } + tags, err := normalizeTags(input.Tags) + if err != nil { + return input, err + } + input.Tags = tags + if input.CustomFields == nil { + input.CustomFields = map[string]any{} + } + if err := validateJSONObject("customFields", input.CustomFields, 256*1024); err != nil { + return input, err + } + return input, nil +} + +func applyProfile(payment *domain.Payment, input UpdatePaymentInput) []string { + changed := make([]string, 0, 10) + setString := func(name string, dst *string, value string) { + if *dst != value { + *dst = value + changed = append(changed, name) + } + } + setString("displayName", &payment.DisplayName, input.DisplayName) + setString("customerName", &payment.CustomerName, input.CustomerName) + setString("customerEmail", &payment.CustomerEmail, input.CustomerEmail) + setString("customerPhone", &payment.CustomerPhone, input.CustomerPhone) + setString("description", &payment.Description, input.Description) + setString("adminNote", &payment.AdminNote, input.AdminNote) + if !sameStrings(payment.Tags, input.Tags) { + payment.Tags = append([]string(nil), input.Tags...) + changed = append(changed, "tags") + } + if !sameJSONObject(payment.CustomFields, input.CustomFields) { + payment.CustomFields = input.CustomFields + changed = append(changed, "customFields") + } + return changed +} + +type profileSnapshot struct { + DisplayName string `json:"displayName,omitempty"` + CustomerName string `json:"customerName,omitempty"` + CustomerEmail string `json:"customerEmail,omitempty"` + CustomerPhone string `json:"customerPhone,omitempty"` + Description string `json:"description,omitempty"` + AdminNote string `json:"adminNote,omitempty"` + Tags []string `json:"tags,omitempty"` + CustomFieldsDigest string `json:"customFieldsDigest,omitempty"` +} + +func profileAuditSnapshot(payment *domain.Payment) profileSnapshot { + if payment == nil { + return profileSnapshot{} + } + return profileSnapshot{ + DisplayName: payment.DisplayName, CustomerName: payment.CustomerName, + CustomerEmail: payment.CustomerEmail, CustomerPhone: payment.CustomerPhone, + Description: payment.Description, AdminNote: payment.AdminNote, + Tags: append([]string(nil), payment.Tags...), CustomFieldsDigest: jsonDigest(payment.CustomFields), + } +} + +func jsonDigest(value any) string { + encoded, err := json.Marshal(value) + if err != nil || len(encoded) == 0 || bytes.Equal(encoded, []byte("null")) { + return "" + } + digest := sha256.Sum256(encoded) + return hex.EncodeToString(digest[:]) +} + +func sameStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func sameJSONObject(existing any, desired map[string]any) bool { + if existing == nil { + existing = map[string]any{} + } + left, leftErr := json.Marshal(existing) + right, rightErr := json.Marshal(desired) + return leftErr == nil && rightErr == nil && bytes.Equal(left, right) +} + +func normalizeTags(values []string) ([]string, error) { + if len(values) > 32 { + return nil, invalid("tags", "must contain at most 32 tags") + } + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, raw := range values { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + if runeLen(value) > 64 { + return nil, invalid("tags", "each tag must be at most 64 characters") + } + key := strings.ToLower(value) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, value) + } + return out, nil +} + +func validateJSONObject(field string, value map[string]any, max int) error { + encoded, err := json.Marshal(value) + if err != nil { + return invalid(field, "must contain valid JSON values") + } + if len(encoded) > max { + return invalid(field, fmt.Sprintf("must be at most %d bytes", max)) + } + return nil +} + +func invalid(field, message string) error { return &ValidationError{Field: field, Message: message} } +func runeLen(value string) int { return len([]rune(value)) } diff --git a/internal/operatoradmin/service_test.go b/internal/operatoradmin/service_test.go new file mode 100644 index 0000000..6ebb78c --- /dev/null +++ b/internal/operatoradmin/service_test.go @@ -0,0 +1,194 @@ +package operatoradmin_test + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/audit" + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/operatoradmin" + "github.com/Phloraxx/payment-api/internal/store" + _ "github.com/Phloraxx/payment-api/migrations" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/types" +) + +func TestUpdatePaymentChangesProfileButPreservesFinancialTruthAndAudits(t *testing.T) { + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + defer app.Cleanup() + db := store.NewPocketBase(app) + now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC) + var paymentID string + err = db.Write(context.Background(), func(tx store.UnitOfWork) error { + payment, err := tx.Payments().Create(store.NewPayment{ + Account: domain.PaymentAccountKotak, RequestedPaise: 50000, PayablePaise: 50017, + CreatedAt: now.Add(-time.Minute), ExpiresAt: now.Add(4 * time.Minute), ReuseAfter: now.Add(24 * time.Hour), + ExternalID: "order-old", IdempotencyKey: "idem-immutable", Metadata: map[string]any{"old": true}, + }) + if err != nil { + return err + } + payment.Status = domain.StatusPaid + payment.RRN = "123456789012" + payment.UPIId = "payer@upi" + payment.PayerName = "Original Payer" + payment.EvidenceSource = "kotak_sms" + payment.EvidenceReference = "sms:123456789012" + payment.PaidAt = now.Add(-30 * time.Second) + payment.ResolvedAt = now.Add(-25 * time.Second) + paymentID = payment.ID + return tx.Payments().Save(payment) + }) + if err != nil { + t.Fatal(err) + } + + var before *domain.Payment + if err := db.View(context.Background(), func(tx store.UnitOfWork) error { + var readErr error + before, readErr = tx.Payments().Get(paymentID) + return readErr + }); err != nil { + t.Fatal(err) + } + + service := operatoradmin.Service{Store: db, Now: func() time.Time { return now }} + updated, err := service.UpdatePayment(context.Background(), operatoradmin.UpdatePaymentInput{ + PaymentID: paymentID, Actor: audit.Actor{ID: "admin-1", Email: "admin@example.com"}, DisplayName: "Workshop registration", + CustomerName: "Sourav P Bijoy", CustomerEmail: "sourav@example.com", + CustomerPhone: "+91 90000 00000", Description: "IEEE workshop", AdminNote: "Verified by coordinator", + Tags: []string{" IEEE ", "VIP", "ieee"}, CustomFields: map[string]any{"semester": "S7"}, + }) + if err != nil { + t.Fatal(err) + } + if updated.DisplayName != "Workshop registration" || updated.CustomerName != "Sourav P Bijoy" { + t.Fatalf("profile not updated: %+v", updated) + } + if !reflect.DeepEqual(updated.Tags, []string{"IEEE", "VIP"}) { + t.Fatalf("tags=%v", updated.Tags) + } + + assertFinancialTruthEqual(t, before, updated) + audits, err := app.FindRecordsByFilter("audit_events", "entity_id = {:id}", "created", 10, 0, map[string]any{"id": paymentID}) + if err != nil { + t.Fatal(err) + } + if len(audits) != 1 || audits[0].GetString("action") != "payment.profile.updated" || audits[0].GetString("actor_email") != "admin@example.com" { + t.Fatalf("audit=%v", audits) + } + details := decodeJSONMap(t, audits[0].Get("details")) + beforeAudit, ok := details["before"].(map[string]any) + if !ok { + t.Fatalf("audit before=%T %#v", details["before"], details["before"]) + } + afterAudit, ok := details["after"].(map[string]any) + if !ok || afterAudit["displayName"] != "Workshop registration" || beforeAudit["displayName"] != nil { + t.Fatalf("audit snapshots before=%#v after=%#v", beforeAudit, afterAudit) + } + encoded, _ := json.Marshal(details) + for _, protected := range []string{"externalId", "metadata", "123456789012", "payer@upi"} { + if strings.Contains(string(encoded), protected) { + t.Fatalf("audit leaked protected creation/evidence field %q: %s", protected, encoded) + } + } +} + +func TestUpdatePaymentNoOpDoesNotCreateAudit(t *testing.T) { + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + defer app.Cleanup() + db := store.NewPocketBase(app) + now := time.Now().UTC() + var paymentID string + if err := db.Write(context.Background(), func(tx store.UnitOfWork) error { + payment, err := tx.Payments().Create(store.NewPayment{Account: domain.PaymentAccountKotak, RequestedPaise: 10000, PayablePaise: 10001, CreatedAt: now, ExpiresAt: now.Add(time.Minute), ReuseAfter: now.Add(time.Hour), IdempotencyKey: "idem-noop", Metadata: map[string]any{}}) + if err == nil { + paymentID = payment.ID + } + return err + }); err != nil { + t.Fatal(err) + } + service := operatoradmin.Service{Store: db} + if _, err := service.UpdatePayment(context.Background(), operatoradmin.UpdatePaymentInput{PaymentID: paymentID, CustomFields: map[string]any{}}); err != nil { + t.Fatal(err) + } + count, err := app.CountRecords("audit_events") + if err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("audit count=%d", count) + } +} + +func TestUpdatePaymentValidatesProfileFields(t *testing.T) { + service := operatoradmin.Service{Store: nil} + _ = service + cases := []operatoradmin.UpdatePaymentInput{ + {}, + {PaymentID: "p", CustomerEmail: "not-an-email"}, + {PaymentID: "p", Tags: make([]string, 33)}, + } + for _, input := range cases { + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + db := store.NewPocketBase(app) + _, err = (&operatoradmin.Service{Store: db}).UpdatePayment(context.Background(), input) + app.Cleanup() + if err == nil { + t.Fatalf("expected validation error for %+v", input) + } + if _, ok := err.(*operatoradmin.ValidationError); !ok { + t.Fatalf("error=%T %v", err, err) + } + } +} + +func assertFinancialTruthEqual(t *testing.T, before, after *domain.Payment) { + t.Helper() + if before.Account != after.Account || before.RequestedPaise != after.RequestedPaise || before.PayablePaise != after.PayablePaise || + before.Status != after.Status || !before.CreatedAt.Equal(after.CreatedAt) || !before.ExpiresAt.Equal(after.ExpiresAt) || + !before.ReuseAfter.Equal(after.ReuseAfter) || before.RRN != after.RRN || before.UPIId != after.UPIId || + before.PayerName != after.PayerName || before.EvidenceSource != after.EvidenceSource || before.EvidenceReference != after.EvidenceReference || + !before.PaidAt.Equal(after.PaidAt) || !before.ResolvedAt.Equal(after.ResolvedAt) || before.IdempotencyKey != after.IdempotencyKey || + before.ExternalID != after.ExternalID || !reflect.DeepEqual(before.Metadata, after.Metadata) { + t.Fatalf("financial truth changed\nbefore=%+v\nafter=%+v", before, after) + } +} + +func decodeJSONMap(t *testing.T, value any) map[string]any { + t.Helper() + var raw []byte + switch item := value.(type) { + case types.JSONRaw: + raw = []byte(item) + case []byte: + raw = item + case string: + raw = []byte(item) + default: + var err error + raw, err = json.Marshal(item) + if err != nil { + t.Fatal(err) + } + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode JSON field %T: %v (%q)", value, err, raw) + } + return out +} diff --git a/internal/operatorview/service.go b/internal/operatorview/service.go new file mode 100644 index 0000000..4ce6060 --- /dev/null +++ b/internal/operatorview/service.go @@ -0,0 +1,636 @@ +package operatorview + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/search" + "github.com/pocketbase/pocketbase/tools/types" +) + +type Service struct{ App core.App } + +type PaymentQueryError struct{ Message string } + +func (e *PaymentQueryError) Error() string { return e.Message } + +func invalidPaymentQuery(message string) error { return &PaymentQueryError{Message: message} } + +func New(app core.App) *Service { return &Service{App: app} } + +type PaymentSummary struct { + ID string `json:"id"` + PaymentAccount string `json:"paymentAccount"` + RequestedAmountPaise int64 `json:"requestedAmountPaise"` + PayableAmountPaise int64 `json:"payableAmountPaise"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + ExpiresAt string `json:"expiresAt"` + PaidAt string `json:"paidAt,omitempty"` + DisplayName string `json:"displayName,omitempty"` + ExternalID string `json:"externalId,omitempty"` + CustomerName string `json:"customerName,omitempty"` +} + +type PaymentDetail struct { + PaymentSummary + CustomerEmail string `json:"customerEmail,omitempty"` + CustomerPhone string `json:"customerPhone,omitempty"` + Description string `json:"description,omitempty"` + AdminNote string `json:"adminNote,omitempty"` + Tags []string `json:"tags"` + Metadata any `json:"metadata"` + CustomFields any `json:"customFields"` + PayerName string `json:"payerName,omitempty"` + UPIID string `json:"upiId,omitempty"` + RRN string `json:"rrn,omitempty"` + EvidenceSource string `json:"evidenceSource,omitempty"` + EvidenceReference string `json:"evidenceReference,omitempty"` + ResolvedAt string `json:"resolvedAt,omitempty"` + ReuseAfter string `json:"reuseAfter,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` +} + +type PaymentQuery struct { + Query string + Status string + Account string + Sort string + Limit int + Offset int +} + +type PaymentPage struct { + Payments []PaymentSummary `json:"payments"` + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type ReviewSummary struct { + ID string `json:"id"` + Kind string `json:"kind"` + Status string `json:"status"` + Severity string `json:"severity"` + PaymentID string `json:"paymentId,omitempty"` + CandidatePaymentIDs []string `json:"candidatePaymentIds,omitempty"` + Reason string `json:"reason"` + OpenedAt string `json:"openedAt"` +} + +type EvidenceDetail struct { + Kind string `json:"kind"` + ID string `json:"id"` + Source string `json:"source,omitempty"` + Sender string `json:"sender,omitempty"` + Subject string `json:"subject,omitempty"` + Amount int64 `json:"amountPaise,omitempty"` + Reference string `json:"reference,omitempty"` + UPIID string `json:"upiId,omitempty"` + PayerName string `json:"payerName,omitempty"` + OccurredAt string `json:"occurredAt,omitempty"` + Description string `json:"description,omitempty"` + Status string `json:"status,omitempty"` + Notes string `json:"notes,omitempty"` +} + +type ReviewDetail struct { + ReviewSummary + Resolution string `json:"resolution,omitempty"` + ResolutionNote string `json:"resolutionNote,omitempty"` + ResolvedAt string `json:"resolvedAt,omitempty"` + Evidence *EvidenceDetail `json:"evidence,omitempty"` +} + +type AlertSummary struct { + ID string `json:"id"` + Kind string `json:"kind"` + Status string `json:"status"` + Severity string `json:"severity"` + Message string `json:"message"` + OccurrenceCount int `json:"occurrenceCount"` + FirstSeenAt string `json:"firstSeenAt"` + LastSeenAt string `json:"lastSeenAt"` + NotificationStatus string `json:"notificationStatus,omitempty"` + NotificationAttempts int `json:"notificationAttempts,omitempty"` + NotificationLastError string `json:"notificationLastError,omitempty"` + NotificationDeliveredAt string `json:"notificationDeliveredAt,omitempty"` +} + +type Overview struct { + PaymentCounts map[string]int64 `json:"paymentCounts"` + OpenReviews int64 `json:"openReviews"` + OpenAlerts int64 `json:"openAlerts"` + Recent []PaymentSummary `json:"recentPayments"` +} + +func (s *Service) Overview(limit int) (Overview, error) { + limit = clampLimit(limit, 8, 20) + counts := map[string]int64{"total": 0, "pending": 0, "paid": 0, "late": 0, "expired": 0, "cancelled": 0} + var err error + counts["total"], err = s.App.CountRecords("payments") + if err != nil { + return Overview{}, err + } + for _, status := range []string{"pending", "paid", "late", "expired", "cancelled"} { + counts[status], err = s.App.CountRecords("payments", dbx.NewExp("status = {:status}", dbx.Params{"status": status})) + if err != nil { + return Overview{}, err + } + } + openReviews, err := s.App.CountRecords("review_cases", dbx.NewExp("status = 'open'")) + if err != nil { + return Overview{}, err + } + openAlerts, err := s.App.CountRecords("alerts", dbx.NewExp("status = 'open'")) + if err != nil { + return Overview{}, err + } + page, err := s.QueryPayments(PaymentQuery{Limit: limit, Sort: "newest"}) + if err != nil { + return Overview{}, err + } + return Overview{PaymentCounts: counts, OpenReviews: openReviews, OpenAlerts: openAlerts, Recent: page.Payments}, nil +} + +func (s *Service) ListPayments(status string, limit int) ([]PaymentSummary, error) { + page, err := s.QueryPayments(PaymentQuery{Status: status, Limit: limit, Sort: "newest"}) + if err != nil { + return nil, err + } + return page.Payments, nil +} + +func (s *Service) QueryPayments(input PaymentQuery) (PaymentPage, error) { + filter, params, err := paymentQueryFilter(input) + if err != nil { + return PaymentPage{}, err + } + sort, err := paymentQuerySort(input.Sort) + if err != nil { + return PaymentPage{}, err + } + limit := clampLimit(input.Limit, 25, 100) + offset := input.Offset + if offset < 0 || offset > 1_000_000 { + return PaymentPage{}, invalidPaymentQuery("invalid payment offset") + } + records, err := s.App.FindRecordsByFilter("payments", filter, sort, limit, offset, params) + if err != nil { + return PaymentPage{}, err + } + total, err := s.countPaymentFilter(filter, params) + if err != nil { + return PaymentPage{}, err + } + items := make([]PaymentSummary, 0, len(records)) + for _, record := range records { + items = append(items, paymentSummary(record)) + } + return PaymentPage{Payments: items, Total: total, Limit: limit, Offset: offset}, nil +} + +func paymentQueryFilter(input PaymentQuery) (string, dbx.Params, error) { + status := strings.TrimSpace(strings.ToLower(input.Status)) + account := strings.TrimSpace(strings.ToLower(input.Account)) + query := strings.TrimSpace(input.Query) + if len(query) > 255 { + return "", nil, invalidPaymentQuery("payment search is too long") + } + if status != "" && !validPaymentStatus(status) { + return "", nil, invalidPaymentQuery("invalid payment status") + } + if account != "" && !validPaymentAccount(account) { + return "", nil, invalidPaymentQuery("invalid payment account") + } + parts := []string{"id != ''"} + params := dbx.Params{} + if status != "" { + parts = append(parts, "status = {:status}") + params["status"] = status + } + if account != "" { + parts = append(parts, "payment_account = {:account}") + params["account"] = account + } + if query != "" { + parts = append(parts, "(id ~ {:query} || external_id ~ {:query} || display_name ~ {:query} || customer_name ~ {:query} || customer_email ~ {:query} || customer_phone ~ {:query} || payer_name ~ {:query} || rrn ~ {:query} || upi_id ~ {:query} || evidence_reference ~ {:query} || description ~ {:query} || admin_note ~ {:query})") + params["query"] = query + } + return strings.Join(parts, " && "), params, nil +} + +func paymentQuerySort(value string) (string, error) { + switch strings.TrimSpace(strings.ToLower(value)) { + case "", "newest": + return "-created_at", nil + case "oldest": + return "created_at", nil + case "amount_asc": + return "payable_amount,created_at", nil + case "amount_desc": + return "-payable_amount,-created_at", nil + case "status": + return "status,-created_at", nil + default: + return "", invalidPaymentQuery("invalid payment sort") + } +} + +func (s *Service) countPaymentFilter(filter string, params dbx.Params) (int64, error) { + collection, err := s.App.FindCollectionByNameOrId("payments") + if err != nil { + return 0, err + } + resolver := core.NewRecordFieldResolver(s.App, collection, nil, true) + expr, err := search.FilterData(filter).BuildExpr(resolver, params) + if err != nil { + return 0, err + } + return s.App.CountRecords(collection, expr) +} + +func (s *Service) GetPayment(id string) (PaymentDetail, error) { + record, err := s.App.FindRecordById("payments", strings.TrimSpace(id)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return PaymentDetail{}, sql.ErrNoRows + } + return PaymentDetail{}, err + } + return PaymentDetail{ + PaymentSummary: paymentSummary(record), + CustomerEmail: record.GetString("customer_email"), + CustomerPhone: record.GetString("customer_phone"), + Description: record.GetString("description"), + AdminNote: record.GetString("admin_note"), + Tags: stringSlice(record.Get("tags")), + Metadata: jsonField(record.Get("metadata")), + CustomFields: jsonField(record.Get("custom_fields")), + PayerName: record.GetString("payer_name"), + UPIID: record.GetString("upi_id"), + RRN: record.GetString("rrn"), + EvidenceSource: record.GetString("evidence_source"), + EvidenceReference: record.GetString("evidence_reference"), + ResolvedAt: dateString(record, "resolved_at"), + ReuseAfter: dateString(record, "reuse_after"), + IdempotencyKey: record.GetString("idempotency_key"), + }, nil +} +func (s *Service) ListReviews(status string, limit int) ([]ReviewSummary, error) { + limit = clampLimit(limit, 50, 100) + status = strings.TrimSpace(strings.ToLower(status)) + filter := "id != ''" + params := dbx.Params{} + if status != "" { + if status != "open" && status != "resolved" && status != "dismissed" { + return nil, fmt.Errorf("invalid review status") + } + filter += " && status = {:status}" + params["status"] = status + } + records, err := s.App.FindRecordsByFilter("review_cases", filter, "-opened_at", limit, 0, params) + if err != nil { + return nil, err + } + out := make([]ReviewSummary, 0, len(records)) + for _, record := range records { + out = append(out, ReviewSummary{ + ID: record.Id, Kind: record.GetString("kind"), Status: record.GetString("status"), Severity: record.GetString("severity"), + PaymentID: record.GetString("payment"), CandidatePaymentIDs: stringSlice(record.Get("candidate_payment_ids")), + Reason: record.GetString("reason"), OpenedAt: dateString(record, "opened_at"), + }) + } + return out, nil +} +func (s *Service) GetReview(id string) (ReviewDetail, error) { + record, err := s.App.FindRecordById("review_cases", strings.TrimSpace(id)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ReviewDetail{}, sql.ErrNoRows + } + return ReviewDetail{}, err + } + summary := ReviewSummary{ + ID: record.Id, Kind: record.GetString("kind"), Status: record.GetString("status"), Severity: record.GetString("severity"), + PaymentID: record.GetString("payment"), CandidatePaymentIDs: stringSlice(record.Get("candidate_payment_ids")), + Reason: record.GetString("reason"), OpenedAt: dateString(record, "opened_at"), + } + detail := ReviewDetail{ReviewSummary: summary, Resolution: record.GetString("resolution"), ResolutionNote: record.GetString("resolution_note"), ResolvedAt: dateString(record, "resolved_at")} + for _, relation := range []struct{ field, collection, kind string }{ + {"sms_event", "sms_events", "sms"}, {"email_event", "email_events", "email"}, {"reconciliation_entry", "reconciliation_entries", "reconciliation"}, + } { + id := record.GetString(relation.field) + if id == "" { + continue + } + evidence, findErr := s.App.FindRecordById(relation.collection, id) + if findErr != nil { + return ReviewDetail{}, findErr + } + detail.Evidence = evidenceDetail(evidence, relation.kind) + break + } + return detail, nil +} + +func evidenceDetail(record *core.Record, kind string) *EvidenceDetail { + occurred := dateString(record, "message_time") + if kind == "reconciliation" { + occurred = dateString(record, "transaction_time") + } + status := record.GetString("processing_status") + if kind == "reconciliation" { + status = record.GetString("status") + } + return &EvidenceDetail{ + Kind: kind, ID: record.Id, Source: record.GetString("source"), Sender: record.GetString("sender"), Subject: record.GetString("subject"), + Amount: int64(record.GetInt("amount")), Reference: record.GetString("rrn"), UPIID: record.GetString("upi_id"), PayerName: record.GetString("payer_name"), + OccurredAt: occurred, Description: record.GetString("description"), Status: status, Notes: record.GetString("notes"), + } +} + +func (s *Service) ListAlerts(status string, limit int) ([]AlertSummary, error) { + limit = clampLimit(limit, 50, 100) + status = strings.TrimSpace(strings.ToLower(status)) + filter := "id != ''" + params := dbx.Params{} + if status != "" { + if status != "open" && status != "resolved" { + return nil, fmt.Errorf("invalid alert status") + } + filter += " && status = {:status}" + params["status"] = status + } + records, err := s.App.FindRecordsByFilter("alerts", filter, "-last_seen_at", limit, 0, params) + if err != nil { + return nil, err + } + out := make([]AlertSummary, 0, len(records)) + for _, record := range records { + out = append(out, AlertSummary{ + ID: record.Id, Kind: record.GetString("kind"), Status: record.GetString("status"), Severity: record.GetString("severity"), + Message: record.GetString("message"), OccurrenceCount: record.GetInt("occurrence_count"), + FirstSeenAt: dateString(record, "first_seen_at"), LastSeenAt: dateString(record, "last_seen_at"), + NotificationStatus: record.GetString("notification_status"), NotificationAttempts: record.GetInt("notification_attempts"), + NotificationLastError: record.GetString("notification_last_error"), NotificationDeliveredAt: dateString(record, "notification_delivered_at"), + }) + } + return out, nil +} +func paymentSummary(record *core.Record) PaymentSummary { + return PaymentSummary{ + ID: record.Id, + PaymentAccount: record.GetString("payment_account"), + RequestedAmountPaise: int64(record.GetInt("requested_amount")), + PayableAmountPaise: int64(record.GetInt("payable_amount")), + Status: record.GetString("status"), + CreatedAt: dateString(record, "created_at"), + ExpiresAt: dateString(record, "expires_at"), + PaidAt: dateString(record, "paid_at"), + DisplayName: record.GetString("display_name"), + ExternalID: record.GetString("external_id"), + CustomerName: record.GetString("customer_name"), + } +} + +func dateString(record *core.Record, field string) string { + value := record.GetDateTime(field).Time() + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339Nano) +} + +func validPaymentStatus(status string) bool { + switch status { + case "pending", "paid", "late", "expired", "cancelled": + return true + default: + return false + } +} +func validPaymentAccount(account string) bool { + switch account { + case "kotak", "slice", "paytm": + return true + default: + return false + } +} + +func jsonField(value any) any { + switch item := value.(type) { + case nil: + return map[string]any{} + case types.JSONRaw: + var out any + if len(item) > 0 && json.Unmarshal(item, &out) == nil { + return out + } + case []byte: + var out any + if len(item) > 0 && json.Unmarshal(item, &out) == nil { + return out + } + case string: + var out any + if strings.TrimSpace(item) != "" && json.Unmarshal([]byte(item), &out) == nil { + return out + } + default: + return item + } + return map[string]any{} +} + +func stringSlice(value any) []string { + switch items := value.(type) { + case []string: + return append([]string(nil), items...) + case []any: + out := make([]string, 0, len(items)) + for _, item := range items { + if text, ok := item.(string); ok && strings.TrimSpace(text) != "" { + out = append(out, text) + } + } + return out + default: + return nil + } +} + +func clampLimit(value, fallback, max int) int { + if value <= 0 { + return fallback + } + if value > max { + return max + } + return value +} + +type ReconciliationRunSummary struct { + ID string `json:"id"` + Filename string `json:"filename"` + Status string `json:"status"` + TotalRows int `json:"totalRows"` + MatchedRows int `json:"matchedRows"` + UnmatchedRows int `json:"unmatchedRows"` + DuplicateRows int `json:"duplicateRows"` + ConflictRows int `json:"conflictRows"` + InvalidRows int `json:"invalidRows"` + Error string `json:"error,omitempty"` + StartedAt string `json:"startedAt"` + CompletedAt string `json:"completedAt,omitempty"` +} +type ReconciliationEntrySummary struct { + ID string `json:"id"` + RowNumber int `json:"rowNumber"` + TransactionTime string `json:"transactionTime,omitempty"` + AmountPaise int64 `json:"amountPaise,omitempty"` + Reference string `json:"reference,omitempty"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + PaymentID string `json:"paymentId,omitempty"` + Notes string `json:"notes,omitempty"` +} +type RefundSummary struct { + ID string `json:"id"` + PaymentID string `json:"paymentId"` + AmountPaise int64 `json:"amountPaise"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Reference string `json:"reference,omitempty"` + ExternalID string `json:"externalId,omitempty"` + RequestedAt string `json:"requestedAt"` + CompletedAt string `json:"completedAt,omitempty"` +} + +func (s *Service) ListReconciliationRuns(limit int) ([]ReconciliationRunSummary, error) { + records, err := s.App.FindRecordsByFilter("reconciliation_runs", "id != ''", "-started_at", clampLimit(limit, 50, 100), 0) + if err != nil { + return nil, err + } + out := make([]ReconciliationRunSummary, 0, len(records)) + for _, r := range records { + out = append(out, ReconciliationRunSummary{ID: r.Id, Filename: r.GetString("filename"), Status: r.GetString("status"), TotalRows: r.GetInt("total_rows"), MatchedRows: r.GetInt("matched_rows"), UnmatchedRows: r.GetInt("unmatched_rows"), DuplicateRows: r.GetInt("duplicate_rows"), ConflictRows: r.GetInt("conflict_rows"), InvalidRows: r.GetInt("invalid_rows"), Error: r.GetString("error"), StartedAt: dateString(r, "started_at"), CompletedAt: dateString(r, "completed_at")}) + } + return out, nil +} +func (s *Service) ListReconciliationEntries(runID string, limit int) ([]ReconciliationEntrySummary, error) { + runID = strings.TrimSpace(runID) + if runID == "" { + return nil, fmt.Errorf("run id is required") + } + records, err := s.App.FindRecordsByFilter("reconciliation_entries", "run = {:run}", "row_number", clampLimit(limit, 250, 500), 0, dbx.Params{"run": runID}) + if err != nil { + return nil, err + } + out := make([]ReconciliationEntrySummary, 0, len(records)) + for _, r := range records { + out = append(out, ReconciliationEntrySummary{ID: r.Id, RowNumber: r.GetInt("row_number"), TransactionTime: dateString(r, "transaction_time"), AmountPaise: int64(r.GetInt("amount")), Reference: r.GetString("rrn"), Description: r.GetString("description"), Status: r.GetString("status"), PaymentID: r.GetString("payment"), Notes: r.GetString("notes")}) + } + return out, nil +} +func (s *Service) ListRefunds(limit int) ([]RefundSummary, error) { + records, err := s.App.FindRecordsByFilter("refunds", "id != ''", "-requested_at", clampLimit(limit, 50, 100), 0) + if err != nil { + return nil, err + } + out := make([]RefundSummary, 0, len(records)) + for _, r := range records { + out = append(out, RefundSummary{ID: r.Id, PaymentID: r.GetString("payment"), AmountPaise: int64(r.GetInt("amount")), Status: r.GetString("status"), Reason: r.GetString("reason"), Reference: r.GetString("reference"), ExternalID: r.GetString("external_id"), RequestedAt: dateString(r, "requested_at"), CompletedAt: dateString(r, "completed_at")}) + } + return out, nil +} + +type OperationalRecord struct { + ID string `json:"id"` + CreatedAt string `json:"createdAt,omitempty"` + Fields map[string]any `json:"fields"` +} +type operationalSpec struct { + collection, sort string + fields, dates []string +} + +func operationalRecordSpec(kind string) (operationalSpec, bool) { + switch strings.TrimSpace(strings.ToLower(kind)) { + case "sms": + return operationalSpec{"sms_events", "-created", []string{"payment_account", "source", "source_event_id", "sender", "body", "amount", "rrn", "upi_id", "payer_name", "processing_status", "matched_payment", "error"}, []string{"message_time"}}, true + case "email": + return operationalSpec{"email_events", "-created", []string{"payment_account", "source", "source_event_id", "sender", "recipient", "subject", "body", "amount", "rrn", "upi_id", "payer_name", "processing_status", "matched_payment", "error"}, []string{"message_time", "received_at"}}, true + case "audit": + return operationalSpec{"audit_events", "-occurred_at", []string{"action", "actor_email", "entity_type", "entity_id", "summary", "details"}, []string{"occurred_at"}}, true + case "webhooks": + return operationalSpec{"webhook_deliveries", "-created", []string{"event_id", "event", "payment", "status", "attempts", "response_code", "last_error"}, []string{"next_attempt_at", "last_attempt_at", "delivered_at"}}, true + default: + return operationalSpec{}, false + } +} +func (s *Service) ListOperationalRecords(kind string, limit int) ([]OperationalRecord, error) { + spec, ok := operationalRecordSpec(kind) + if !ok { + return nil, fmt.Errorf("invalid operational record kind") + } + records, err := s.App.FindRecordsByFilter(spec.collection, "id != ''", spec.sort, clampLimit(limit, 50, 100), 0) + if err != nil { + return nil, err + } + out := make([]OperationalRecord, 0, len(records)) + for _, record := range records { + fields := make(map[string]any, len(spec.fields)+len(spec.dates)) + for _, field := range spec.fields { + if value := record.Get(field); value != nil && value != "" { + fields[field] = value + } + } + for _, field := range spec.dates { + if value := dateString(record, field); value != "" { + fields[field] = value + } + } + out = append(out, OperationalRecord{ID: record.Id, CreatedAt: dateString(record, "created"), Fields: fields}) + } + return out, nil +} + +type RazorpayOrderSummary struct { + ID string `json:"id"` + AmountPaise int64 `json:"amountPaise"` + Currency string `json:"currency"` + Status string `json:"status"` + ExternalID string `json:"externalId,omitempty"` + RazorpayOrderID string `json:"razorpayOrderId,omitempty"` + RazorpayPaymentID string `json:"razorpayPaymentId,omitempty"` + ProviderStatus string `json:"providerStatus,omitempty"` + PaymentMethod string `json:"paymentMethod,omitempty"` + AmountRefunded int64 `json:"amountRefunded,omitempty"` + Error string `json:"error,omitempty"` + CreatedAt string `json:"createdAt"` + CapturedAt string `json:"capturedAt,omitempty"` +} + +func (s *Service) ListRazorpayOrders(mode string, limit int) ([]RazorpayOrderSummary, error) { + collection := "razorpay_test_orders" + if strings.TrimSpace(strings.ToLower(mode)) != "test" { + return nil, fmt.Errorf("invalid razorpay mode") + } + records, err := s.App.FindRecordsByFilter(collection, "id != ''", "-created_at", clampLimit(limit, 50, 100), 0) + if err != nil { + return nil, err + } + out := make([]RazorpayOrderSummary, 0, len(records)) + for _, r := range records { + out = append(out, RazorpayOrderSummary{ID: r.Id, AmountPaise: int64(r.GetInt("amount")), Currency: r.GetString("currency"), Status: r.GetString("status"), ExternalID: r.GetString("external_id"), RazorpayOrderID: r.GetString("razorpay_order_id"), RazorpayPaymentID: r.GetString("razorpay_payment_id"), ProviderStatus: r.GetString("provider_status"), PaymentMethod: r.GetString("payment_method"), AmountRefunded: int64(r.GetInt("amount_refunded")), Error: r.GetString("error"), CreatedAt: dateString(r, "created_at"), CapturedAt: dateString(r, "captured_at")}) + } + return out, nil +} diff --git a/internal/operatorview/service_test.go b/internal/operatorview/service_test.go new file mode 100644 index 0000000..5ec7b94 --- /dev/null +++ b/internal/operatorview/service_test.go @@ -0,0 +1,170 @@ +package operatorview_test + +import ( + "strings" + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/config" + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/operatorview" + "github.com/Phloraxx/payment-api/internal/payments" + "github.com/Phloraxx/payment-api/internal/store" + _ "github.com/Phloraxx/payment-api/migrations" + "github.com/pocketbase/pocketbase/tests" +) + +func TestOverviewAndPaymentViewsUseTypedContract(t *testing.T) { + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + defer app.Cleanup() + + cfg := config.Config{ + TestMode: true, + PaymentTTL: 5 * time.Minute, + AmountQuarantine: 24 * time.Hour, + } + paymentService := payments.NewService(app, cfg, nil) + paymentService.SuffixStart = func() (int64, error) { return 1, nil } + payment, _, err := paymentService.Create(payments.CreateInput{AmountRupees: 125, PaymentAccount: "kotak"}) + if err != nil { + t.Fatal(err) + } + + view := operatorview.New(app) + overview, err := view.Overview(5) + if err != nil { + t.Fatal(err) + } + if overview.PaymentCounts["total"] != 1 || overview.PaymentCounts["pending"] != 1 { + t.Fatalf("counts=%+v", overview.PaymentCounts) + } + if len(overview.Recent) != 1 || overview.Recent[0].ID != payment.ID { + t.Fatalf("recent=%+v", overview.Recent) + } + + pending, err := view.ListPayments("pending", 10) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 || pending[0].PayableAmountPaise != payment.PayablePaise { + t.Fatalf("pending=%+v", pending) + } + + detail, err := view.GetPayment(payment.ID) + if err != nil { + t.Fatal(err) + } + if detail.ID != payment.ID || detail.PaymentAccount != "kotak" || detail.RRN != "" { + t.Fatalf("detail=%+v", detail) + } +} + +func TestPaymentQuerySupportsSearchFiltersPagingAndTypedDetail(t *testing.T) { + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + defer app.Cleanup() + db := store.NewPocketBase(app) + now := time.Date(2026, 8, 29, 10, 0, 0, 0, time.UTC) + var targetID string + seed := []struct { + account domain.PaymentAccount + status domain.PaymentStatus + external, display, customer, email, rrn string + amount int64 + }{ + {domain.PaymentAccountKotak, domain.StatusPaid, "ORDER-ALPHA", "AI Workshop", "Alice Student", "alice@example.com", "111111111111", 10101}, + {domain.PaymentAccountSlice, domain.StatusPending, "ORDER-BETA", "Membership", "Bob Student", "bob@example.com", "", 20202}, + {domain.PaymentAccountPaytm, domain.StatusPaid, "ORDER-GAMMA", "Buildathon", "Carol Student", "carol@example.com", "333333333333", 30303}, + } + if err := db.Write(t.Context(), func(tx store.UnitOfWork) error { + for i, item := range seed { + payment, err := tx.Payments().Create(store.NewPayment{ + Account: item.account, RequestedPaise: item.amount - 1, PayablePaise: item.amount, + CreatedAt: now.Add(time.Duration(i) * time.Minute), ExpiresAt: now.Add(time.Hour), ReuseAfter: now.Add(24 * time.Hour), + ExternalID: item.external, IdempotencyKey: item.external + "-idem", Metadata: map[string]any{"seed": i}, + }) + if err != nil { + return err + } + payment.Status = item.status + payment.DisplayName = item.display + payment.CustomerName = item.customer + payment.CustomerEmail = item.email + payment.CustomerPhone = "+91-900000000" + string(rune('0'+i)) + payment.RRN = item.rrn + payment.UPIId = strings.ToLower(strings.Fields(item.customer)[0]) + "@upi" + payment.EvidenceReference = "evidence:" + item.external + if item.external == "ORDER-GAMMA" { + payment.PayerName = "Treasurer Alias" + payment.Description = "Hardware security workshop registration" + payment.AdminNote = "Priority reconciliation contact" + } + payment.Tags = []string{"seed", item.external} + payment.CustomFields = map[string]any{"batch": "S7"} + if err := tx.Payments().Save(payment); err != nil { + return err + } + if item.external == "ORDER-GAMMA" { + targetID = payment.ID + } + } + return nil + }); err != nil { + t.Fatal(err) + } + + view := operatorview.New(app) + page, err := view.QueryPayments(operatorview.PaymentQuery{Query: "Carol", Account: "paytm", Status: "paid", Sort: "newest", Limit: 25}) + if err != nil { + t.Fatal(err) + } + if page.Total != 1 || len(page.Payments) != 1 || page.Payments[0].ID != targetID || page.Payments[0].DisplayName != "Buildathon" { + t.Fatalf("search page=%+v", page) + } + for _, query := range []string{"Treasurer Alias", "Hardware security", "Priority reconciliation"} { + page, err = view.QueryPayments(operatorview.PaymentQuery{Query: query, Limit: 25}) + if err != nil || page.Total != 1 || len(page.Payments) != 1 || page.Payments[0].ID != targetID { + t.Fatalf("expanded search %q page=%+v err=%v", query, page, err) + } + } + page, err = view.QueryPayments(operatorview.PaymentQuery{Sort: "oldest", Limit: 1, Offset: 1}) + if err != nil { + t.Fatal(err) + } + if page.Total != 3 || page.Limit != 1 || page.Offset != 1 || len(page.Payments) != 1 || page.Payments[0].ExternalID != "ORDER-BETA" { + t.Fatalf("paged=%+v", page) + } + if _, err := view.QueryPayments(operatorview.PaymentQuery{Sort: "status", Limit: 25}); err != nil { + t.Fatalf("status sort: %v", err) + } + if _, err := view.QueryPayments(operatorview.PaymentQuery{Sort: "drop table"}); err == nil { + t.Fatal("expected invalid sort") + } + if _, err := view.QueryPayments(operatorview.PaymentQuery{Account: "unknown"}); err == nil { + t.Fatal("expected invalid account") + } + if _, err := view.QueryPayments(operatorview.PaymentQuery{Query: strings.Repeat("x", 256)}); err == nil { + t.Fatal("expected long query rejection") + } + + detail, err := view.GetPayment(targetID) + if err != nil { + t.Fatal(err) + } + if detail.CustomerEmail != "carol@example.com" || detail.IdempotencyKey != "ORDER-GAMMA-idem" || detail.ReuseAfter == "" { + t.Fatalf("detail=%+v", detail) + } + metadata, ok := detail.Metadata.(map[string]any) + if !ok || metadata["seed"] == nil { + t.Fatalf("metadata=%T %#v", detail.Metadata, detail.Metadata) + } + custom, ok := detail.CustomFields.(map[string]any) + if !ok || custom["batch"] != "S7" { + t.Fatalf("custom=%T %#v", detail.CustomFields, detail.CustomFields) + } +} diff --git a/internal/paymentemail/service.go b/internal/paymentemail/service.go index aa7daf4..96b4ff8 100644 --- a/internal/paymentemail/service.go +++ b/internal/paymentemail/service.go @@ -1,6 +1,7 @@ package paymentemail import ( + "context" "database/sql" "errors" "strings" @@ -9,7 +10,7 @@ import ( "github.com/Phloraxx/payment-api/internal/domain" "github.com/Phloraxx/payment-api/internal/payments" - "github.com/pocketbase/dbx" + "github.com/Phloraxx/payment-api/internal/store" "github.com/pocketbase/pocketbase/core" ) @@ -34,7 +35,7 @@ type ReviewInput struct { } type ReviewWriter interface { - OpenEmailReviewInApp(app core.App, input ReviewInput) (string, error) + OpenEmailReview(uow store.UnitOfWork, input ReviewInput) (string, error) } type Result struct { @@ -47,7 +48,7 @@ type Result struct { } type Service struct { - App core.App + Store store.Database Payments *payments.Service Reviews ReviewWriter AllowedSender string @@ -56,7 +57,7 @@ type Service struct { } func NewService(app core.App, paymentService *payments.Service, allowedSender, authServID string) *Service { - return &Service{App: app, Payments: paymentService, AllowedSender: strings.ToLower(strings.TrimSpace(allowedSender)), AuthServID: strings.TrimSpace(authServID), Now: time.Now} + return &Service{Store: store.NewPocketBase(app), Payments: paymentService, AllowedSender: strings.ToLower(strings.TrimSpace(allowedSender)), AuthServID: strings.TrimSpace(authServID), Now: time.Now} } func (s *Service) Ingest(input Input) (Result, error) { @@ -86,9 +87,10 @@ func (s *Service) Ingest(input Input) (Result, error) { var result Result var queued bool - err := s.App.RunInTransaction(func(tx core.App) error { + err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + events := uow.EmailEvents() if input.SourceEventID != "" { - existing, err := tx.FindFirstRecordByFilter("email_events", "source = {:source} && source_event_id = {:id}", dbx.Params{"source": input.Source, "id": input.SourceEventID}) + existing, err := events.FindBySourceEvent(input.Source, input.SourceEventID) if err == nil { result = resultFromEvent(existing) result.Action = "duplicate_event" @@ -99,36 +101,14 @@ func (s *Service) Ingest(input Input) (Result, error) { return err } } - - collection, err := tx.FindCollectionByNameOrId("email_events") - if err != nil { - return err - } - event := core.NewRecord(collection) - event.Set("source", input.Source) - event.Set("source_event_id", input.SourceEventID) - event.Set("envelope_sender", truncateRunes(strings.TrimSpace(input.EnvelopeSender), 255)) - event.Set("recipient", truncateRunes(strings.TrimSpace(input.Recipient), 255)) - event.Set("sender", truncateRunes(input.Message.From, 255)) - event.Set("subject", truncateRunes(input.Message.Subject, 1024)) - event.Set("body", truncateRunes(input.Message.Body, 64*1024)) - event.Set("payment_account", string(domain.PaymentAccountSlice)) - event.Set("message_time", messageTime) - event.Set("received_at", receivedAt) - event.Set("auth_result", truncateRunes(strings.Join(input.Message.AuthenticationResults, "\n"), 8192)) - event.Set("processing_status", "received") - if input.RawPayload != nil { - event.Set("raw_payload", input.RawPayload) - } - if err := tx.Save(event); err != nil { + event := &domain.EmailEvent{Source: input.Source, SourceEventID: input.SourceEventID, EnvelopeSender: truncateRunes(strings.TrimSpace(input.EnvelopeSender), 255), Recipient: truncateRunes(strings.TrimSpace(input.Recipient), 255), Sender: truncateRunes(input.Message.From, 255), Subject: truncateRunes(input.Message.Subject, 1024), Body: truncateRunes(input.Message.Body, 64*1024), Account: domain.PaymentAccountSlice, MessageTime: messageTime, ReceivedAt: receivedAt, AuthResult: truncateRunes(strings.Join(input.Message.AuthenticationResults, "\n"), 8192), ProcessingStatus: "received", RawPayload: input.RawPayload} + if err := events.Create(event); err != nil { return err } - result.EventID = event.Id - + result.EventID = event.ID if !strings.EqualFold(input.Message.From, s.AllowedSender) { - event.Set("processing_status", "ignored") - event.Set("error", "sender is not the configured bank notification address") - if err := tx.Save(event); err != nil { + event.ProcessingStatus, event.Error = "ignored", "sender is not the configured bank notification address" + if err := events.Save(event); err != nil { return err } result.Status, result.Action = "ignored", "ignored_sender" @@ -138,9 +118,8 @@ func (s *Service) Ingest(input Input) (Result, error) { parsed.Account = domain.PaymentAccountSlice parsed.OccurredAt = messageTime if errors.Is(parseErr, ErrUnrecognized) { - event.Set("processing_status", "ignored") - event.Set("error", ErrUnrecognized.Error()) - if err := tx.Save(event); err != nil { + event.ProcessingStatus, event.Error = "ignored", ErrUnrecognized.Error() + if err := events.Save(event); err != nil { return err } result.Status, result.Action = "ignored", "ignored_non_payment_email" @@ -151,74 +130,65 @@ func (s *Service) Ingest(input Input) (Result, error) { domainPart = domainPart[at+1:] } if input.Source != "manual" && !AuthenticatedSender(input.Message.AuthenticationResults, s.AuthServID, domainPart) { - event.Set("processing_status", "error") - event.Set("error", "bank sender authentication did not pass through the trusted mail receiver") - if err := tx.Save(event); err != nil { + event.ProcessingStatus, event.Error = "error", "bank sender authentication did not pass through the trusted mail receiver" + if err := events.Save(event); err != nil { return err } - caseID, err := s.openReview(tx, ReviewInput{Kind: "email_auth_failed", Severity: "critical", EmailEventID: event.Id, Reason: "Payment-looking email failed trusted DKIM/DMARC authentication", OpenedAt: now}) + caseID, err := s.openReview(uow, ReviewInput{Kind: "email_auth_failed", Severity: "critical", EmailEventID: event.ID, Reason: "Payment-looking email failed trusted DKIM/DMARC authentication", OpenedAt: now}) if err != nil { return err } result.Status, result.Action, result.ReviewCaseID = "review_required", "email_auth_failed", caseID return nil } - if parseErr != nil { - event.Set("processing_status", "error") - event.Set("error", parseErr.Error()) - if err := tx.Save(event); err != nil { + event.ProcessingStatus, event.Error = "error", parseErr.Error() + if err := events.Save(event); err != nil { return err } - caseID, err := s.openReview(tx, ReviewInput{Kind: "parse_error", Severity: "warning", EmailEventID: event.Id, Reason: "Bank-credit-like email could not be parsed: " + parseErr.Error(), OpenedAt: now}) + caseID, err := s.openReview(uow, ReviewInput{Kind: "parse_error", Severity: "warning", EmailEventID: event.ID, Reason: "Bank-credit-like email could not be parsed: " + parseErr.Error(), OpenedAt: now}) if err != nil { return err } result.Status, result.Action, result.ReviewCaseID = "review_required", "parse_error", caseID return nil } - event.Set("amount", parsed.AmountPaise) - event.Set("rrn", parsed.RRN) - event.Set("upi_id", parsed.UPIId) - event.Set("payer_name", parsed.PayerName) - event.Set("processing_status", "parsed") + event.AmountPaise, event.RRN, event.UPIID, event.PayerName, event.ProcessingStatus = parsed.AmountPaise, parsed.RRN, parsed.UPIId, parsed.PayerName, "parsed" if strings.TrimSpace(parsed.RRN) == "" { - event.Set("processing_status", "error") - event.Set("error", "bank credit email has no usable UPI reference/RRN") - if err := tx.Save(event); err != nil { + event.ProcessingStatus, event.Error = "error", "bank credit email has no usable UPI reference/RRN" + if err := events.Save(event); err != nil { return err } - candidates, err := candidatePaymentIDs(tx, parsed.Account, parsed.AmountPaise, now) + candidates, err := candidatePaymentIDs(uow, parsed.Account, parsed.AmountPaise, now) if err != nil { return err } - caseID, err := s.openReview(tx, ReviewInput{Kind: "missing_rrn", Severity: "warning", EmailEventID: event.Id, CandidatePaymentIDs: candidates, Reason: "Bank credit email has an amount but no usable UPI reference/RRN", OpenedAt: now}) + caseID, err := s.openReview(uow, ReviewInput{Kind: "missing_rrn", Severity: "warning", EmailEventID: event.ID, CandidatePaymentIDs: candidates, Reason: "Bank credit email has an amount but no usable UPI reference/RRN", OpenedAt: now}) if err != nil { return err } result.Status, result.Action, result.ReviewCaseID = "review_required", "missing_rrn", caseID return nil } - - payment, action, matchQueued, matchErr := s.Payments.MatchInApp(tx, parsed, now) + payment, outcome, matchQueued, matchErr := s.Payments.MatchBankEvidence(uow, parsed, now) + action := string(outcome) queued = queued || matchQueued if matchErr != nil { var dErr *domain.Error if errors.As(matchErr, &dErr) { - event.Set("processing_status", "error") - event.Set("error", dErr.Message) - if err := tx.Save(event); err != nil { + event.ProcessingStatus, event.Error = "error", dErr.Message + if err := events.Save(event); err != nil { return err } kind := "ambiguous" if dErr.Code == "RRN_AMOUNT_MISMATCH" || dErr.Code == "RRN_ACCOUNT_MISMATCH" { kind = "rrn_conflict" } - candidates, err := candidatePaymentIDs(tx, parsed.Account, parsed.AmountPaise, now) + candidates, err := candidatePaymentIDs(uow, parsed.Account, parsed.AmountPaise, now) if err != nil { return err } - caseID, err := s.openReview(tx, ReviewInput{Kind: kind, Severity: "critical", EmailEventID: event.Id, CandidatePaymentIDs: candidates, Reason: dErr.Message, OpenedAt: now}) + caseID, err := s.openReview(uow, ReviewInput{Kind: kind, Severity: "critical", EmailEventID: event.ID, CandidatePaymentIDs: candidates, Reason: dErr.Message, OpenedAt: now}) if err != nil { return err } @@ -229,21 +199,16 @@ func (s *Service) Ingest(input Input) (Result, error) { } switch action { case "marked_paid", "marked_late": - event.Set("processing_status", "matched") - event.Set("matched_payment", payment.Id) - result.Status, result.PaymentID = "matched", payment.Id + event.ProcessingStatus, event.MatchedPaymentID, result.Status, result.PaymentID = "matched", payment.ID, "matched", payment.ID case "duplicate_rrn": - event.Set("processing_status", "duplicate") - event.Set("matched_payment", payment.Id) - result.Status, result.PaymentID, result.Duplicate = "duplicate", payment.Id, true + event.ProcessingStatus, event.MatchedPaymentID, result.Status, result.PaymentID, result.Duplicate = "duplicate", payment.ID, "duplicate", payment.ID, true case "unmatched": - event.Set("processing_status", "unmatched") - event.Set("error", "no eligible payment has this exact amount") - candidates, err := candidatePaymentIDs(tx, parsed.Account, parsed.AmountPaise, now) + event.ProcessingStatus, event.Error = "unmatched", "no eligible payment has this exact amount" + candidates, err := candidatePaymentIDs(uow, parsed.Account, parsed.AmountPaise, now) if err != nil { return err } - caseID, err := s.openReview(tx, ReviewInput{Kind: "unmatched", Severity: "warning", EmailEventID: event.Id, CandidatePaymentIDs: candidates, Reason: "No eligible payment has this exact amount", OpenedAt: now}) + caseID, err := s.openReview(uow, ReviewInput{Kind: "unmatched", Severity: "warning", EmailEventID: event.ID, CandidatePaymentIDs: candidates, Reason: "No eligible payment has this exact amount", OpenedAt: now}) if err != nil { return err } @@ -252,7 +217,7 @@ func (s *Service) Ingest(input Input) (Result, error) { return domain.New("INTERNAL_EMAIL_MATCH_STATE", "unexpected email matching result", 500) } result.Action = action - return tx.Save(event) + return events.Save(event) }) if err != nil { return Result{}, err @@ -270,28 +235,28 @@ func (s *Service) now() time.Time { return s.Now().UTC() } -func (s *Service) openReview(app core.App, input ReviewInput) (string, error) { +func (s *Service) openReview(uow store.UnitOfWork, input ReviewInput) (string, error) { if s.Reviews == nil { return "", nil } - return s.Reviews.OpenEmailReviewInApp(app, input) + return s.Reviews.OpenEmailReview(uow, input) } -func resultFromEvent(event *core.Record) Result { - return Result{EventID: event.Id, Status: event.GetString("processing_status"), PaymentID: event.GetString("matched_payment")} +func resultFromEvent(event *domain.EmailEvent) Result { + return Result{EventID: event.ID, Status: event.ProcessingStatus, PaymentID: event.MatchedPaymentID} } -func candidatePaymentIDs(app core.App, account domain.PaymentAccount, amountPaise int64, now time.Time) ([]string, error) { +func candidatePaymentIDs(uow store.UnitOfWork, account domain.PaymentAccount, amountPaise int64, now time.Time) ([]string, error) { if amountPaise <= 0 { return nil, nil } - records, err := app.FindRecordsByFilter("payments", "payment_account = {:account} && payable_amount = {:amount} && reuse_after > {:now}", "-created_at", 10, 0, dbx.Params{"account": string(account), "amount": amountPaise, "now": now.UTC().Format("2006-01-02 15:04:05.000Z")}) + payments, err := uow.Payments().ListFingerprintCandidates(account, amountPaise, now, 10) if err != nil { return nil, err } - ids := make([]string, 0, len(records)) - for _, record := range records { - ids = append(ids, record.Id) + ids := make([]string, 0, len(payments)) + for _, payment := range payments { + ids = append(ids, payment.ID) } return ids, nil } diff --git a/internal/paymentemail/service_test.go b/internal/paymentemail/service_test.go index be90db6..a36bb5e 100644 --- a/internal/paymentemail/service_test.go +++ b/internal/paymentemail/service_test.go @@ -7,8 +7,8 @@ import ( "github.com/Phloraxx/payment-api/internal/config" "github.com/Phloraxx/payment-api/internal/domain" "github.com/Phloraxx/payment-api/internal/payments" + "github.com/Phloraxx/payment-api/internal/store" _ "github.com/Phloraxx/payment-api/migrations" - "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tests" ) @@ -16,7 +16,7 @@ type reviewRecorder struct { inputs []ReviewInput } -func (r *reviewRecorder) OpenEmailReviewInApp(_ core.App, input ReviewInput) (string, error) { +func (r *reviewRecorder) OpenEmailReview(_ store.UnitOfWork, input ReviewInput) (string, error) { r.inputs = append(r.inputs, input) return "review-case", nil } diff --git a/internal/payments/matcher.go b/internal/payments/matcher.go new file mode 100644 index 0000000..067f6b7 --- /dev/null +++ b/internal/payments/matcher.go @@ -0,0 +1,168 @@ +package payments + +import ( + "database/sql" + "errors" + "net/http" + "strings" + "time" + + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/store" +) + +// MatchEvidence is the single automatic payment matcher used by every trusted +// evidence adapter. Source parsing/authentication happens before this boundary; +// persistence is accessed only through typed repositories. +func (s *Service) MatchEvidence(uow store.UnitOfWork, evidence domain.Evidence, now time.Time) (*domain.Payment, domain.MatchOutcome, bool, error) { + now = now.UTC() + account, _, err := s.paymentAccount(string(evidence.Account)) + if err != nil { + return nil, domain.MatchNotMatchable, false, err + } + evidence.Account = domain.PaymentAccount(account) + evidence = evidence.NormalizeWindow(now) + evidence.Reference = strings.TrimSpace(evidence.Reference) + if evidence.AmountPaise <= 0 || evidence.Reference == "" { + return nil, domain.MatchNotMatchable, false, domain.New( + "EVIDENCE_NOT_MATCHABLE", "payment evidence requires an exact amount and unique reference", http.StatusUnprocessableEntity, + ) + } + + _, outcomes, codes, err := evidenceIdentity(evidence.ReferenceKind) + if err != nil { + return nil, domain.MatchNotMatchable, false, err + } + repo := uow.Payments() + existing, err := repo.FindByEvidenceReference(evidence.ReferenceKind, evidence.Reference) + if err == nil { + if existing.Account != evidence.Account { + return nil, outcomes.accountMismatch, false, domain.New(codes.accountMismatch, codes.accountMessage, http.StatusConflict) + } + if existing.PayablePaise != evidence.AmountPaise { + return nil, outcomes.amountMismatch, false, domain.New(codes.amountMismatch, codes.amountMessage, http.StatusConflict) + } + return existing, outcomes.duplicate, false, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, domain.MatchError, false, err + } + + createdBefore := evidence.OccurredUntil.Add(EvidenceTimestampTolerance) + onTime, err := repo.FindOnTimeCandidates(evidence.Account, evidence.AmountPaise, evidence.OccurredFrom, createdBefore, now) + if err != nil { + return nil, domain.MatchError, false, err + } + if len(onTime) > 1 { + return nil, domain.MatchAmbiguous, false, domain.AmbiguousMatch() + } + if len(onTime) == 1 { + payment := onTime[0] + applyNormalizedEvidence(payment, evidence, domain.StatusPaid, now, s.Config.AmountQuarantine) + if err := repo.Save(payment); err != nil { + return nil, domain.MatchError, false, err + } + if err := s.scheduleTyped(uow, "payment.paid", payment, now); err != nil { + return nil, domain.MatchError, false, err + } + return payment, domain.MatchMarkedPaid, true, nil + } + + late, err := repo.FindLateCandidates(evidence.Account, evidence.AmountPaise, evidence.OccurredFrom, createdBefore, now) + if err != nil { + return nil, domain.MatchError, false, err + } + if len(late) > 1 { + return nil, domain.MatchAmbiguous, false, domain.AmbiguousMatch() + } + if len(late) == 1 { + payment := late[0] + if payment.Status == domain.StatusPending { + expiredView := *payment + expiredView.Status = domain.StatusExpired + expiredView.ResolvedAt = now + if err := s.scheduleTyped(uow, "payment.expired", &expiredView, now); err != nil { + return nil, domain.MatchError, false, err + } + } + applyNormalizedEvidence(payment, evidence, domain.StatusLate, now, s.Config.AmountQuarantine) + if err := repo.Save(payment); err != nil { + return nil, domain.MatchError, false, err + } + if err := s.scheduleTyped(uow, "payment.late", payment, now); err != nil { + return nil, domain.MatchError, false, err + } + return payment, domain.MatchMarkedLate, true, nil + } + return nil, domain.MatchUnmatched, false, nil +} + +type evidenceOutcomes struct { + duplicate domain.MatchOutcome + accountMismatch domain.MatchOutcome + amountMismatch domain.MatchOutcome +} + +type evidenceCodes struct { + accountMismatch string + accountMessage string + amountMismatch string + amountMessage string +} + +func evidenceIdentity(kind domain.EvidenceReferenceKind) (string, evidenceOutcomes, evidenceCodes, error) { + switch kind { + case domain.EvidenceReferenceRRN: + return "rrn", evidenceOutcomes{ + duplicate: domain.MatchDuplicateRRN, + accountMismatch: domain.MatchRRNAccountMismatch, + amountMismatch: domain.MatchRRNAmountMismatch, + }, evidenceCodes{ + accountMismatch: "RRN_ACCOUNT_MISMATCH", + accountMessage: "the UPI reference was already recorded for a different payment account", + amountMismatch: "RRN_AMOUNT_MISMATCH", + amountMessage: "the UPI reference was already recorded with a different amount", + }, nil + case domain.EvidenceReferenceRelay: + return "evidence_reference", evidenceOutcomes{ + duplicate: domain.MatchDuplicateEvidence, + accountMismatch: domain.MatchEvidenceAccountMismatch, + amountMismatch: domain.MatchEvidenceAmountMismatch, + }, evidenceCodes{ + accountMismatch: "EVIDENCE_ACCOUNT_MISMATCH", + accountMessage: "the notification evidence was already recorded for a different payment account", + amountMismatch: "EVIDENCE_AMOUNT_MISMATCH", + amountMessage: "the notification evidence was already recorded with a different amount", + }, nil + default: + return "", evidenceOutcomes{}, evidenceCodes{}, domain.New( + "EVIDENCE_REFERENCE_INVALID", + "payment evidence has an unsupported reference kind", + http.StatusUnprocessableEntity, + ) + } +} +func applyNormalizedEvidence(payment *domain.Payment, evidence domain.Evidence, status domain.PaymentStatus, now time.Time, quarantine time.Duration) { + paidAt := evidence.OccurredFrom.UTC() + if paidAt.IsZero() || paidAt.After(now) { + paidAt = now.UTC() + } + payment.Status = status + payment.PayerName = strings.TrimSpace(evidence.PayerName) + if evidence.UPIID != "" { + payment.UPIId = strings.TrimSpace(evidence.UPIID) + } + switch evidence.ReferenceKind { + case domain.EvidenceReferenceRRN: + payment.RRN = strings.TrimSpace(evidence.Reference) + case domain.EvidenceReferenceRelay: + payment.EvidenceSource = string(evidence.Source) + payment.EvidenceReference = strings.TrimSpace(evidence.Reference) + } + payment.PaidAt = paidAt + payment.ResolvedAt = now.UTC() + candidate := now.UTC().Add(quarantine) + if payment.ReuseAfter.IsZero() || candidate.After(payment.ReuseAfter) { + payment.ReuseAfter = candidate + } +} diff --git a/internal/payments/service.go b/internal/payments/service.go index a3e6b15..ba2ead2 100644 --- a/internal/payments/service.go +++ b/internal/payments/service.go @@ -1,6 +1,7 @@ package payments import ( + "context" "crypto/rand" "database/sql" "encoding/json" @@ -17,20 +18,19 @@ import ( "github.com/Phloraxx/payment-api/internal/config" "github.com/Phloraxx/payment-api/internal/domain" "github.com/Phloraxx/payment-api/internal/money" - "github.com/pocketbase/dbx" + "github.com/Phloraxx/payment-api/internal/store" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/types" ) const EvidenceTimestampTolerance = 2 * time.Second type WebhookScheduler interface { - Schedule(app core.App, event string, payment *core.Record, at time.Time) error + SchedulePayment(uow store.UnitOfWork, event string, payment *domain.Payment, at time.Time) error Wake() } type Service struct { - App core.App + Store store.Database Config config.Config Webhooks WebhookScheduler Now func() time.Time @@ -54,7 +54,7 @@ type MatchResult struct { func NewService(app core.App, cfg config.Config, webhooks WebhookScheduler) *Service { return &Service{ - App: app, + Store: store.NewPocketBase(app), Config: cfg, Webhooks: webhooks, Now: time.Now, @@ -62,7 +62,7 @@ func NewService(app core.App, cfg config.Config, webhooks WebhookScheduler) *Ser } } -type CreateGate func(core.App) error +type CreateGate func(store.UnitOfWork) error func (s *Service) Create(input CreateInput) (*domain.Payment, bool, error) { return s.CreateGuarded(input, nil) @@ -93,27 +93,18 @@ func (s *Service) CreateGuarded(input CreateInput, gate CreateGate) (*domain.Pay return nil, false, err } now := s.now() - var result *core.Record - var reused bool - var queued bool - - err = s.App.RunInTransaction(func(tx core.App) error { - expired, err := s.ExpireDueInApp(tx, now) - if err != nil { - return err - } - queued = expired > 0 + var result *domain.Payment + reused := false + err = s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + repo := uow.Payments() if input.IdempotencyKey != "" { - existing, findErr := tx.FindFirstRecordByData("payments", "idempotency_key", input.IdempotencyKey) + existing, findErr := repo.FindByIdempotencyKey(input.IdempotencyKey) if findErr == nil { - if int64(existing.GetInt("requested_amount")) != requested || - existing.GetString("payment_account") != account || - existing.GetString("external_id") != input.ExternalID || - !metadataEqual(existing.Get("metadata"), metadata) { + if existing.RequestedPaise != requested || existing.Account != domain.PaymentAccount(account) || existing.ExternalID != input.ExternalID || !metadataEqual(existing.Metadata, metadata) { return domain.IdempotencyConflict() } - result = existing.Clone() + result = existing reused = true return nil } @@ -123,7 +114,7 @@ func (s *Service) CreateGuarded(input CreateInput, gate CreateGate) (*domain.Pay } if gate != nil { - if err := gate(tx); err != nil { + if err := gate(uow); err != nil { return err } } @@ -135,45 +126,33 @@ func (s *Service) CreateGuarded(input CreateInput, gate CreateGate) (*domain.Pay if start < 1 || start > 99 { return fmt.Errorf("invalid amount fingerprint start %d", start) } - collection, err := tx.FindCollectionByNameOrId("payments") - if err != nil { - return err - } expiresAt := now.Add(s.Config.PaymentTTL) reuseAfter := expiresAt.Add(s.Config.AmountQuarantine) - for i := int64(0); i < 99; i++ { suffix := ((start - 1 + i) % 99) + 1 candidate := requested + suffix - blocked, err := tx.FindFirstRecordByFilter( - "payments", - "payable_amount = {:amount} && reuse_after > {:now}", - dbx.Params{"amount": candidate, "now": filterDate(now)}, - ) - if err == nil && blocked != nil { - continue - } - if err != nil && !errors.Is(err, sql.ErrNoRows) { + blocked, err := repo.IsFingerprintBlocked(candidate, now) + if err != nil { return err } - - record := core.NewRecord(collection) - record.Set("created_at", now) - record.Set("payment_account", account) - record.Set("requested_amount", requested) - record.Set("payable_amount", candidate) - record.Set("status", string(domain.StatusPending)) - record.Set("expires_at", expiresAt) - record.Set("reuse_after", reuseAfter) - record.Set("external_id", input.ExternalID) - record.Set("idempotency_key", input.IdempotencyKey) - if metadata != nil { - record.Set("metadata", metadata) + if blocked { + continue } - if err := tx.Save(record); err != nil { + created, err := repo.Create(store.NewPayment{ + Account: domain.PaymentAccount(account), RequestedPaise: requested, PayablePaise: candidate, + CreatedAt: now, ExpiresAt: expiresAt, ReuseAfter: reuseAfter, + ExternalID: input.ExternalID, IdempotencyKey: input.IdempotencyKey, Metadata: metadata, + }) + if err != nil { + if input.IdempotencyKey != "" { + if existing, findErr := repo.FindByIdempotencyKey(input.IdempotencyKey); findErr == nil { + result, reused = existing, true + return nil + } + } return err } - result = record.Clone() + result = created return nil } return domain.CapacityExhausted() @@ -181,77 +160,64 @@ func (s *Service) CreateGuarded(input CreateInput, gate CreateGate) (*domain.Pay if err != nil { return nil, false, err } - if queued { - s.WakeWebhooks() - } - return FromRecord(result), reused, nil + return result, reused, nil } func (s *Service) Get(id string) (*domain.Payment, error) { - now := s.now() - var result *core.Record - var queued bool - err := s.App.RunInTransaction(func(tx core.App) error { - expired, err := s.ExpireDueInApp(tx, now) - if err != nil { - return err - } - queued = expired > 0 - record, err := tx.FindRecordById("payments", id) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return domain.PaymentNotFound() - } - return err - } - result = record.Clone() - return nil + var payment *domain.Payment + err := s.Store.View(context.Background(), func(uow store.UnitOfWork) error { + var err error + payment, err = uow.Payments().Get(strings.TrimSpace(id)) + return err }) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.PaymentNotFound() + } return nil, err } - if queued { - s.WakeWebhooks() + if payment.Status == domain.StatusPending && !payment.ExpiresAt.IsZero() && !payment.ExpiresAt.After(s.now()) { + payment.Status = domain.StatusExpired } - return FromRecord(result), nil + return payment, nil } func (s *Service) Cancel(id string) (*domain.Payment, error) { now := s.now() - var result *core.Record - var queued bool - err := s.App.RunInTransaction(func(tx core.App) error { - expired, err := s.ExpireDueInApp(tx, now) - if err != nil { - return err - } - queued = expired > 0 - record, err := tx.FindRecordById("payments", id) + var result *domain.Payment + queued := false + err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + payment, err := uow.Payments().Get(strings.TrimSpace(id)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return domain.PaymentNotFound() } return err } - status := record.GetString("status") - if status == string(domain.StatusCancelled) { - result = record.Clone() + if payment.Status == domain.StatusCancelled { + result = payment return nil } - if status != string(domain.StatusPending) { - return domain.PaymentResolved(status) + if payment.Status != domain.StatusPending { + return domain.PaymentResolved(string(payment.Status)) + } + if !payment.ExpiresAt.IsZero() && !payment.ExpiresAt.After(now) { + return domain.PaymentResolved(string(domain.StatusExpired)) } - record.Set("status", string(domain.StatusCancelled)) - record.Set("resolved_at", now) - extendReuseAfter(record, now.Add(s.Config.AmountQuarantine)) - if err := tx.Save(record); err != nil { + payment.Status = domain.StatusCancelled + payment.ResolvedAt = now + candidate := now.Add(s.Config.AmountQuarantine) + if payment.ReuseAfter.IsZero() || candidate.After(payment.ReuseAfter) { + payment.ReuseAfter = candidate + } + if err := uow.Payments().Save(payment); err != nil { return err } - if err := s.schedule(tx, "payment.cancelled", record, now); err != nil { + if err := s.scheduleTyped(uow, "payment.cancelled", payment, now); err != nil { return err } queued = true - result = record.Clone() + result = payment return nil }) if err != nil { @@ -260,7 +226,7 @@ func (s *Service) Cancel(id string) (*domain.Payment, error) { if queued { s.WakeWebhooks() } - return FromRecord(result), nil + return result, nil } func (s *Service) Match(parsed domain.ParsedSMS) (*MatchResult, error) { @@ -268,12 +234,12 @@ func (s *Service) Match(parsed domain.ParsedSMS) (*MatchResult, error) { return nil, domain.New("SMS_NOT_MATCHABLE", "bank SMS requires an exact amount and RRN", http.StatusUnprocessableEntity) } now := s.now() - var record *core.Record - var action string + var payment *domain.Payment + var outcome domain.MatchOutcome var queued bool - err := s.App.RunInTransaction(func(tx core.App) error { + err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { var err error - record, action, queued, err = s.MatchInApp(tx, parsed, now) + payment, outcome, queued, err = s.MatchBankEvidence(uow, parsed, now) return err }) if err != nil { @@ -282,105 +248,24 @@ func (s *Service) Match(parsed domain.ParsedSMS) (*MatchResult, error) { if queued { s.WakeWebhooks() } - return &MatchResult{Payment: FromRecord(record), Action: action}, nil + return &MatchResult{Payment: payment, Action: string(outcome)}, nil } -// MatchInApp applies exact-amount matching inside the caller's transaction. -// It returns whether outgoing webhook work was queued so the caller can wake the -// delivery loop only after the transaction commits. -func (s *Service) MatchInApp(tx core.App, parsed domain.ParsedSMS, now time.Time) (*core.Record, string, bool, error) { - now = now.UTC() - account, _, err := s.paymentAccount(string(parsed.Account)) - if err != nil { - return nil, "not_matchable", false, err - } - rrn := strings.TrimSpace(parsed.RRN) - evidenceAt := parsed.OccurredAt.UTC() - if evidenceAt.IsZero() || evidenceAt.After(now) { - evidenceAt = now - } - if rrn == "" || parsed.AmountPaise <= 0 { - return nil, "not_matchable", false, domain.New("SMS_NOT_MATCHABLE", "bank SMS requires an exact amount and RRN", http.StatusUnprocessableEntity) - } - - existing, err := tx.FindFirstRecordByData("payments", "rrn", rrn) - if err == nil { - if existing.GetString("payment_account") != account { - return nil, "rrn_account_mismatch", false, domain.New("RRN_ACCOUNT_MISMATCH", "the UPI reference was already recorded for a different payment account", http.StatusConflict) - } - if int64(existing.GetInt("payable_amount")) != parsed.AmountPaise { - return nil, "rrn_amount_mismatch", false, domain.New("RRN_AMOUNT_MISMATCH", "the UPI reference was already recorded with a different amount", http.StatusConflict) - } - return existing, "duplicate_rrn", false, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return nil, "error", false, err - } - - createdBefore := evidenceAt.Add(EvidenceTimestampTolerance) - - // Match by when the bank says the credit occurred, not merely when the SMS - // happened to reach PayGate. This prevents an on-time payment from becoming - // "late" solely because the bank/phone delivered the SMS after expiry. - onTime, err := tx.FindRecordsByFilter( - "payments", - "payment_account = {:account} && payable_amount = {:amount} && created_at <= {:createdBefore} && ((status = 'pending' && expires_at >= {:evidenceAt}) || (status = 'expired' && expires_at >= {:evidenceAt} && reuse_after > {:now}) || (status = 'cancelled' && resolved_at != '' && resolved_at >= {:evidenceAt} && reuse_after > {:now}))", - "created", - 2, - 0, - dbx.Params{"account": account, "amount": parsed.AmountPaise, "now": filterDate(now), "evidenceAt": filterDate(evidenceAt), "createdBefore": filterDate(createdBefore)}, - ) - if err != nil { - return nil, "error", false, err - } - if len(onTime) > 1 { - return nil, "ambiguous", false, domain.AmbiguousMatch() - } - if len(onTime) == 1 { - record := onTime[0] - applyEvidence(record, parsed, domain.StatusPaid, now, s.Config.AmountQuarantine) - if err := tx.Save(record); err != nil { - return nil, "error", false, err - } - if err := s.schedule(tx, "payment.paid", record, now); err != nil { - return nil, "error", false, err - } - return record, "marked_paid", true, nil - } - - expired, err := s.ExpireDueInApp(tx, now) - if err != nil { - return nil, "error", false, err - } - queued := expired > 0 - - late, err := tx.FindRecordsByFilter( - "payments", - "payment_account = {:account} && payable_amount = {:amount} && (status = 'expired' || status = 'cancelled') && reuse_after > {:now} && created_at <= {:createdBefore}", - "-created", - 2, - 0, - dbx.Params{"account": account, "amount": parsed.AmountPaise, "now": filterDate(now), "evidenceAt": filterDate(evidenceAt), "createdBefore": filterDate(createdBefore)}, - ) - if err != nil { - return nil, "error", queued, err - } - if len(late) > 1 { - return nil, "ambiguous", queued, domain.AmbiguousMatch() +// MatchBankEvidence normalizes trusted bank evidence into the common matcher. +func (s *Service) MatchBankEvidence(uow store.UnitOfWork, parsed domain.ParsedSMS, now time.Time) (*domain.Payment, domain.MatchOutcome, bool, error) { + if parsed.AmountPaise <= 0 || strings.TrimSpace(parsed.RRN) == "" { + return nil, domain.MatchNotMatchable, false, domain.New("SMS_NOT_MATCHABLE", "bank SMS requires an exact amount and RRN", http.StatusUnprocessableEntity) } - if len(late) == 1 { - record := late[0] - applyEvidence(record, parsed, domain.StatusLate, now, s.Config.AmountQuarantine) - if err := tx.Save(record); err != nil { - return nil, "error", queued, err - } - if err := s.schedule(tx, "payment.late", record, now); err != nil { - return nil, "error", queued, err - } - return record, "marked_late", true, nil + source := domain.EvidenceSourceBankSMS + if parsed.Account == domain.PaymentAccountSlice { + source = domain.EvidenceSourceBankEmail } - - return nil, "unmatched", queued, nil + return s.MatchEvidence(uow, domain.Evidence{ + Account: parsed.Account, AmountPaise: parsed.AmountPaise, + OccurredFrom: parsed.OccurredAt, OccurredUntil: parsed.OccurredAt, + Reference: strings.TrimSpace(parsed.RRN), ReferenceKind: domain.EvidenceReferenceRRN, + Source: source, PayerName: parsed.PayerName, UPIID: parsed.UPIId, + }, now) } type NotificationEvidence struct { @@ -392,131 +277,34 @@ type NotificationEvidence struct { Reference string } -// MatchNotificationInApp matches a trusted Paytm for Business notification by -// account, exact DDM amount, and notification occurrence time. Unlike bank SMS -// evidence, Paytm push notifications do not necessarily expose a UPI RRN, so a -// unique relay evidence reference is used for idempotency instead. -func (s *Service) MatchNotificationInApp(tx core.App, evidence NotificationEvidence, now time.Time) (*core.Record, string, bool, error) { - now = now.UTC() - account, _, err := s.paymentAccount(string(evidence.Account)) - if err != nil { - return nil, "not_matchable", false, err - } - if account != string(domain.PaymentAccountPaytm) { - return nil, "not_matchable", false, domain.New("NOTIFICATION_ACCOUNT_INVALID", "notification evidence is only valid for the Paytm account", http.StatusBadRequest) - } - reference := strings.TrimSpace(evidence.Reference) - if evidence.AmountPaise <= 0 || reference == "" { - return nil, "not_matchable", false, domain.New("NOTIFICATION_NOT_MATCHABLE", "Paytm notification requires an exact amount and evidence reference", http.StatusUnprocessableEntity) - } - evidenceAt := evidence.OccurredAt.UTC() - evidenceUntil := evidence.OccurredUntil.UTC() - if evidenceAt.IsZero() || evidenceAt.After(now) { - evidenceAt = now - evidenceUntil = now +// MatchNotification applies normalized Paytm relay evidence through the same matcher. +func (s *Service) MatchNotification(uow store.UnitOfWork, evidence NotificationEvidence, now time.Time) (*domain.Payment, string, bool, error) { + if evidence.Account != domain.PaymentAccountPaytm { + return nil, string(domain.MatchNotMatchable), false, domain.New("NOTIFICATION_ACCOUNT_INVALID", "notification evidence is only valid for the Paytm account", http.StatusBadRequest) } - if evidenceUntil.IsZero() || evidenceUntil.Before(evidenceAt) { - evidenceUntil = evidenceAt - } - if evidenceUntil.After(now) { - evidenceUntil = now - } - - existing, err := tx.FindFirstRecordByData("payments", "evidence_reference", reference) - if err == nil { - if existing.GetString("payment_account") != account { - return nil, "evidence_account_mismatch", false, domain.New("EVIDENCE_ACCOUNT_MISMATCH", "the notification evidence was already recorded for a different payment account", http.StatusConflict) - } - if int64(existing.GetInt("payable_amount")) != evidence.AmountPaise { - return nil, "evidence_amount_mismatch", false, domain.New("EVIDENCE_AMOUNT_MISMATCH", "the notification evidence was already recorded with a different amount", http.StatusConflict) - } - return existing, "duplicate_evidence", false, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return nil, "error", false, err - } - - createdBefore := evidenceUntil.Add(EvidenceTimestampTolerance) - onTime, err := tx.FindRecordsByFilter( - "payments", - "payment_account = {:account} && payable_amount = {:amount} && created_at <= {:createdBefore} && ((status = 'pending' && expires_at >= {:evidenceAt}) || (status = 'expired' && expires_at >= {:evidenceAt} && reuse_after > {:now}) || (status = 'cancelled' && resolved_at != '' && resolved_at >= {:evidenceAt} && reuse_after > {:now}))", - "created", 2, 0, - dbx.Params{"account": account, "amount": evidence.AmountPaise, "now": filterDate(now), "evidenceAt": filterDate(evidenceAt), "createdBefore": filterDate(createdBefore)}, - ) - if err != nil { - return nil, "error", false, err - } - if len(onTime) > 1 { - return nil, "ambiguous", false, domain.AmbiguousMatch() - } - if len(onTime) == 1 { - record := onTime[0] - applyNotificationEvidence(record, evidence, domain.StatusPaid, now, s.Config.AmountQuarantine) - if err := tx.Save(record); err != nil { - return nil, "error", false, err - } - if err := s.schedule(tx, "payment.paid", record, now); err != nil { - return nil, "error", false, err - } - return record, "marked_paid", true, nil - } - - expired, err := s.ExpireDueInApp(tx, now) - if err != nil { - return nil, "error", false, err - } - queued := expired > 0 - late, err := tx.FindRecordsByFilter( - "payments", - "payment_account = {:account} && payable_amount = {:amount} && (status = 'expired' || status = 'cancelled') && reuse_after > {:now} && created_at <= {:createdBefore}", - "-created", 2, 0, - dbx.Params{"account": account, "amount": evidence.AmountPaise, "now": filterDate(now), "createdBefore": filterDate(createdBefore)}, - ) - if err != nil { - return nil, "error", queued, err - } - if len(late) > 1 { - return nil, "ambiguous", queued, domain.AmbiguousMatch() - } - if len(late) == 1 { - record := late[0] - applyNotificationEvidence(record, evidence, domain.StatusLate, now, s.Config.AmountQuarantine) - if err := tx.Save(record); err != nil { - return nil, "error", queued, err - } - if err := s.schedule(tx, "payment.late", record, now); err != nil { - return nil, "error", queued, err - } - return record, "marked_late", true, nil - } - return nil, "unmatched", queued, nil -} - -func applyNotificationEvidence(record *core.Record, evidence NotificationEvidence, status domain.PaymentStatus, now time.Time, quarantine time.Duration) { - paidAt := evidence.OccurredAt.UTC() - if paidAt.IsZero() || paidAt.After(now) { - paidAt = now.UTC() + if evidence.AmountPaise <= 0 || strings.TrimSpace(evidence.Reference) == "" { + return nil, string(domain.MatchNotMatchable), false, domain.New("NOTIFICATION_NOT_MATCHABLE", "Paytm notification requires an exact amount and evidence reference", http.StatusUnprocessableEntity) } - record.Set("status", string(status)) - record.Set("payer_name", strings.TrimSpace(evidence.PayerName)) - record.Set("evidence_source", "paytm_notification") - record.Set("evidence_reference", strings.TrimSpace(evidence.Reference)) - record.Set("paid_at", paidAt) - record.Set("resolved_at", now.UTC()) - extendReuseAfter(record, now.UTC().Add(quarantine)) + payment, outcome, queued, err := s.MatchEvidence(uow, domain.Evidence{ + Account: evidence.Account, AmountPaise: evidence.AmountPaise, + OccurredFrom: evidence.OccurredAt, OccurredUntil: evidence.OccurredUntil, + Reference: strings.TrimSpace(evidence.Reference), ReferenceKind: domain.EvidenceReferenceRelay, + Source: domain.EvidenceSourcePaytmNotification, PayerName: evidence.PayerName, + }, now) + return payment, string(outcome), queued, err } -// ManualMatchInApp explicitly links reviewed bank evidence to one payment. It -// still enforces exact amount equality and global RRN uniqueness; the operator -// chooses the payment, but cannot override those monetary invariants. -func (s *Service) ManualMatchInApp(tx core.App, paymentID string, parsed domain.ParsedSMS, now time.Time) (*core.Record, string, bool, error) { +// ManualMatch applies reviewed bank evidence to one explicitly selected payment. +// Operator choice does not bypass account, exact amount, RRN uniqueness, +// creation-time or quarantine invariants. +func (s *Service) ManualMatch(uow store.UnitOfWork, paymentID string, parsed domain.ParsedSMS, now time.Time) (*domain.Payment, string, bool, error) { now = now.UTC() paymentID = strings.TrimSpace(paymentID) rrn := strings.TrimSpace(parsed.RRN) if paymentID == "" || parsed.AmountPaise <= 0 || rrn == "" { return nil, "not_matchable", false, domain.New("MANUAL_MATCH_INVALID", "payment, exact amount and bank reference are required", http.StatusBadRequest) } - record, err := tx.FindRecordById("payments", paymentID) + payment, err := uow.Payments().Get(paymentID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, "not_found", false, domain.PaymentNotFound() @@ -527,180 +315,157 @@ func (s *Service) ManualMatchInApp(tx core.App, paymentID string, parsed domain. if accountErr != nil { return nil, "not_matchable", false, accountErr } - if record.GetString("payment_account") != account { + if payment.Account != domain.PaymentAccount(account) { return nil, "account_mismatch", false, domain.New("PAYMENT_ACCOUNT_MISMATCH", "bank evidence belongs to a different payment account", http.StatusConflict) } - if int64(record.GetInt("payable_amount")) != parsed.AmountPaise { + if payment.PayablePaise != parsed.AmountPaise { return nil, "amount_mismatch", false, domain.New("MANUAL_AMOUNT_MISMATCH", "bank evidence amount does not equal the payment payable amount", http.StatusConflict) } - existing, findErr := tx.FindFirstRecordByData("payments", "rrn", rrn) - if findErr == nil && existing.Id != record.Id { + existing, findErr := uow.Payments().FindByEvidenceReference(domain.EvidenceReferenceRRN, rrn) + if findErr == nil && existing.ID != payment.ID { return nil, "rrn_conflict", false, domain.New("RRN_ALREADY_ASSIGNED", "the bank reference is already assigned to another payment", http.StatusConflict) } if findErr != nil && !errors.Is(findErr, sql.ErrNoRows) { return nil, "error", false, findErr } - status := domain.PaymentStatus(record.GetString("status")) - if status == domain.StatusPaid || status == domain.StatusLate { - if record.GetString("rrn") == rrn { - return record, "already_matched", false, nil + if payment.Status == domain.StatusPaid || payment.Status == domain.StatusLate { + if payment.RRN == rrn { + return payment, "already_matched", false, nil } - return nil, "resolved", false, domain.PaymentResolved(string(status)) + return nil, "resolved", false, domain.PaymentResolved(string(payment.Status)) } evidenceAt := parsed.OccurredAt.UTC() if evidenceAt.IsZero() || evidenceAt.After(now) { evidenceAt = now } - createdAt := record.GetDateTime("created_at").Time() - if !createdAt.IsZero() && evidenceAt.Add(EvidenceTimestampTolerance).Before(createdAt) { + if !payment.CreatedAt.IsZero() && evidenceAt.Add(EvidenceTimestampTolerance).Before(payment.CreatedAt) { return nil, "stale", false, domain.New("STALE_BANK_EVIDENCE", "bank evidence predates this payment", http.StatusConflict) } - reuseAfter := record.GetDateTime("reuse_after").Time() - if !reuseAfter.IsZero() && evidenceAt.After(reuseAfter) { + if !payment.ReuseAfter.IsZero() && evidenceAt.After(payment.ReuseAfter) { return nil, "stale", false, domain.New("PAYMENT_QUARANTINE_ELAPSED", "bank transaction occurred after this amount fingerprint became reusable", http.StatusConflict) } target := domain.StatusLate - expiresAt := record.GetDateTime("expires_at").Time() - resolvedAt := record.GetDateTime("resolved_at").Time() - if status == domain.StatusPending && (expiresAt.IsZero() || !evidenceAt.After(expiresAt)) { + if payment.Status == domain.StatusPending && (payment.ExpiresAt.IsZero() || !evidenceAt.After(payment.ExpiresAt)) { target = domain.StatusPaid - } else if status == domain.StatusExpired && !expiresAt.IsZero() && !evidenceAt.After(expiresAt) { + } else if payment.Status == domain.StatusExpired && !payment.ExpiresAt.IsZero() && !evidenceAt.After(payment.ExpiresAt) { target = domain.StatusPaid - } else if status == domain.StatusCancelled && !resolvedAt.IsZero() && !evidenceAt.After(resolvedAt) { + } else if payment.Status == domain.StatusCancelled && !payment.ResolvedAt.IsZero() && !evidenceAt.After(payment.ResolvedAt) { target = domain.StatusPaid } - applyEvidence(record, parsed, target, now, s.Config.AmountQuarantine) - if err := tx.Save(record); err != nil { + applyBankEvidence(payment, parsed, target, now, s.Config.AmountQuarantine) + if err := uow.Payments().Save(payment); err != nil { return nil, "error", false, err } - event := "payment.late" - action := "marked_late" + event, action := "payment.late", "marked_late" if target == domain.StatusPaid { - event = "payment.paid" - action = "marked_paid" + event, action = "payment.paid", "marked_paid" } - if err := s.schedule(tx, event, record, now); err != nil { + if err := s.scheduleTyped(uow, event, payment, now); err != nil { return nil, "error", false, err } - return record, action, true, nil + return payment, action, true, nil } -func applyEvidence(record *core.Record, parsed domain.ParsedSMS, status domain.PaymentStatus, now time.Time, quarantine time.Duration) { +func applyBankEvidence(payment *domain.Payment, parsed domain.ParsedSMS, status domain.PaymentStatus, now time.Time, quarantine time.Duration) { paidAt := parsed.OccurredAt.UTC() if paidAt.IsZero() || paidAt.After(now) { paidAt = now.UTC() } - record.Set("status", string(status)) - record.Set("rrn", strings.TrimSpace(parsed.RRN)) - record.Set("upi_id", strings.TrimSpace(parsed.UPIId)) - record.Set("payer_name", strings.TrimSpace(parsed.PayerName)) - record.Set("paid_at", paidAt) - record.Set("resolved_at", now.UTC()) - extendReuseAfter(record, now.UTC().Add(quarantine)) -} - -func extendReuseAfter(record *core.Record, candidate time.Time) { - existing := record.GetDateTime("reuse_after").Time() - if existing.IsZero() || candidate.After(existing) { - record.Set("reuse_after", candidate.UTC()) + payment.Status = status + payment.RRN = strings.TrimSpace(parsed.RRN) + payment.UPIId = strings.TrimSpace(parsed.UPIId) + payment.PayerName = strings.TrimSpace(parsed.PayerName) + payment.PaidAt = paidAt + payment.ResolvedAt = now.UTC() + candidate := now.UTC().Add(quarantine) + if payment.ReuseAfter.IsZero() || candidate.After(payment.ReuseAfter) { + payment.ReuseAfter = candidate } } +const ( + expireBatchSize = 100 + expireMaxBatches = 10 +) + func (s *Service) ExpireDue() (int, error) { now := s.now() - count := 0 - err := s.App.RunInTransaction(func(tx core.App) error { - var err error - count, err = s.ExpireDueInApp(tx, now) - return err - }) - if err == nil && count > 0 { + total := 0 + for batch := 0; batch < expireMaxBatches; batch++ { + count := 0 + err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + var err error + count, err = s.ExpireDueUoW(uow, now) + return err + }) + if err != nil { + return total, err + } + total += count + if count < expireBatchSize { + break + } + } + if total > 0 { s.WakeWebhooks() } - return count, err + return total, nil } -// ExpireDueInApp changes only currently pending records whose persisted expiry -// timestamp is due. Existing reuse_after is retained because it was fixed at -// creation as expires_at + quarantine. -func (s *Service) ExpireDueInApp(tx core.App, now time.Time) (int, error) { +// ExpireDueUoW processes one bounded batch using typed repositories. Payment +// state and any outgoing event are committed atomically by the caller's UoW. +func (s *Service) ExpireDueUoW(uow store.UnitOfWork, now time.Time) (int, error) { now = now.UTC() - records, err := tx.FindRecordsByFilter( - "payments", - "status = 'pending' && expires_at <= {:now}", - "expires_at", - 0, - 0, - dbx.Params{"now": filterDate(now)}, - ) + payments, err := uow.Payments().ListDue(now, expireBatchSize) if err != nil { return 0, err } - for _, record := range records { - record.Set("status", string(domain.StatusExpired)) - record.Set("resolved_at", now) - if record.GetDateTime("reuse_after").IsZero() { - record.Set("reuse_after", now.Add(s.Config.AmountQuarantine)) + for _, payment := range payments { + payment.Status = domain.StatusExpired + payment.ResolvedAt = now + if payment.ReuseAfter.IsZero() { + payment.ReuseAfter = now.Add(s.Config.AmountQuarantine) } - if err := tx.Save(record); err != nil { + if err := uow.Payments().Save(payment); err != nil { return 0, err } - if err := s.schedule(tx, "payment.expired", record, now); err != nil { + if err := s.scheduleTyped(uow, "payment.expired", payment, now); err != nil { return 0, err } } - return len(records), nil + return len(payments), nil } func (s *Service) Stats() (map[string]int64, error) { - if _, err := s.ExpireDue(); err != nil { - return nil, err - } + now := s.now() result := map[string]int64{ "total": 0, "pending": 0, "paid": 0, "expired": 0, "cancelled": 0, "late": 0, } - records, err := s.App.FindAllRecords("payments") - if err != nil { + var payments []*domain.Payment + if err := s.Store.View(context.Background(), func(uow store.UnitOfWork) error { + var err error + payments, err = uow.Payments().ListAll() + return err + }); err != nil { return nil, err } - for _, record := range records { + for _, payment := range payments { result["total"]++ - status := record.GetString("status") - if _, ok := result[status]; ok { - result[status]++ + status := payment.Status + if status == domain.StatusPending && !payment.ExpiresAt.IsZero() && !payment.ExpiresAt.After(now) { + status = domain.StatusExpired + } + if _, ok := result[string(status)]; ok { + result[string(status)]++ } } return result, nil } -func FromRecord(record *core.Record) *domain.Payment { - if record == nil { - return nil - } - return &domain.Payment{ - ID: record.Id, - Account: domain.PaymentAccount(record.GetString("payment_account")), - RequestedPaise: int64(record.GetInt("requested_amount")), - PayablePaise: int64(record.GetInt("payable_amount")), - Status: domain.PaymentStatus(record.GetString("status")), - ExpiresAt: record.GetDateTime("expires_at").Time(), - ReuseAfter: record.GetDateTime("reuse_after").Time(), - RRN: record.GetString("rrn"), - UPIId: record.GetString("upi_id"), - PayerName: record.GetString("payer_name"), - EvidenceSource: record.GetString("evidence_source"), - EvidenceReference: record.GetString("evidence_reference"), - PaidAt: record.GetDateTime("paid_at").Time(), - ResolvedAt: record.GetDateTime("resolved_at").Time(), - ExternalID: record.GetString("external_id"), - IdempotencyKey: record.GetString("idempotency_key"), - } -} - func PublicPayment(payment *domain.Payment) map[string]any { if payment == nil { return nil @@ -771,11 +536,11 @@ func (s *Service) WakeWebhooks() { } } -func (s *Service) schedule(tx core.App, event string, payment *core.Record, at time.Time) error { +func (s *Service) scheduleTyped(uow store.UnitOfWork, event string, payment *domain.Payment, at time.Time) error { if s.Webhooks == nil { return nil } - return s.Webhooks.Schedule(tx, event, payment, at) + return s.Webhooks.SchedulePayment(uow, event, payment, at) } func (s *Service) now() time.Time { @@ -827,14 +592,6 @@ func metadataEqual(a, b any) bool { return reflect.DeepEqual(normalizeMetadata(a), normalizeMetadata(b)) } -func filterDate(t time.Time) string { - value, err := types.ParseDateTime(t.UTC()) - if err != nil { - return t.UTC().Format(time.RFC3339Nano) - } - return value.String() -} - func formatTime(t time.Time) string { if t.IsZero() { return "" @@ -868,18 +625,12 @@ type CapacitySnapshot struct { func (s *Service) Capacity() (CapacitySnapshot, error) { now := s.now() - if _, err := s.ExpireDue(); err != nil { - return CapacitySnapshot{}, err - } - records, err := s.App.FindRecordsByFilter( - "payments", - "reuse_after > {:now}", - "requested_amount,payable_amount", - 0, - 0, - dbx.Params{"now": filterDate(now)}, - ) - if err != nil { + var payments []*domain.Payment + if err := s.Store.View(context.Background(), func(uow store.UnitOfWork) error { + var err error + payments, err = uow.Payments().ListBlocked(now) + return err + }); err != nil { return CapacitySnapshot{}, err } type counts struct { @@ -888,15 +639,14 @@ func (s *Service) Capacity() (CapacitySnapshot, error) { amounts map[int64]struct{} } byRequested := map[int64]*counts{} - for _, record := range records { - requested := int64(record.GetInt("requested_amount")) - entry := byRequested[requested] + for _, payment := range payments { + entry := byRequested[payment.RequestedPaise] if entry == nil { entry = &counts{amounts: map[int64]struct{}{}} - byRequested[requested] = entry + byRequested[payment.RequestedPaise] = entry } - entry.amounts[int64(record.GetInt("payable_amount"))] = struct{}{} - if record.GetString("status") == string(domain.StatusPending) { + entry.amounts[payment.PayablePaise] = struct{}{} + if payment.Status == domain.StatusPending && (payment.ExpiresAt.IsZero() || payment.ExpiresAt.After(now)) { entry.pending++ } else { entry.quarantined++ @@ -919,14 +669,9 @@ func (s *Service) Capacity() (CapacitySnapshot, error) { result.WarningPools++ } result.Pools = append(result.Pools, CapacityPool{ - RequestedAmountPaise: requested, - RequestedAmount: money.FormatPaise(requested), - Pending: entry.pending, - Quarantined: entry.quarantined, - Blocked: blocked, - Available: available, - UtilizationPercent: utilization, - Level: level, + RequestedAmountPaise: requested, RequestedAmount: money.FormatPaise(requested), + Pending: entry.pending, Quarantined: entry.quarantined, Blocked: blocked, + Available: available, UtilizationPercent: utilization, Level: level, }) } sort.Slice(result.Pools, func(i, j int) bool { diff --git a/internal/payments/service_test.go b/internal/payments/service_test.go index 1425693..d3ab6f2 100644 --- a/internal/payments/service_test.go +++ b/internal/payments/service_test.go @@ -1,6 +1,7 @@ package payments import ( + "context" "errors" "net/url" "strings" @@ -11,8 +12,8 @@ import ( "github.com/Phloraxx/payment-api/internal/config" "github.com/Phloraxx/payment-api/internal/domain" "github.com/Phloraxx/payment-api/internal/money" + "github.com/Phloraxx/payment-api/internal/store" _ "github.com/Phloraxx/payment-api/migrations" - "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tests" ) @@ -105,10 +106,10 @@ func TestPaytmQROnlyCreateResponseEncodesExactAmountWithoutTransactionNote(t *te } func TestCreateGuardedSkipsReadinessGateForIdempotentReplay(t *testing.T) { - service, _, _ := paymentTestService(t) + service, app, _ := paymentTestService(t) input := CreateInput{AmountRupees: 25, PaymentAccount: "kotak", IdempotencyKey: "guarded-replay"} gateCalls := 0 - first, replayed, err := service.CreateGuarded(input, func(core.App) error { + first, replayed, err := service.CreateGuarded(input, func(store.UnitOfWork) error { gateCalls++ return nil }) @@ -116,7 +117,7 @@ func TestCreateGuardedSkipsReadinessGateForIdempotentReplay(t *testing.T) { t.Fatalf("first guarded create = %+v replayed=%v calls=%d err=%v", first, replayed, gateCalls, err) } - replay, replayed, err := service.CreateGuarded(input, func(core.App) error { + replay, replayed, err := service.CreateGuarded(input, func(store.UnitOfWork) error { t.Fatal("readiness gate must not run for an exact idempotency replay") return errors.New("unreachable") }) @@ -124,17 +125,17 @@ func TestCreateGuardedSkipsReadinessGateForIdempotentReplay(t *testing.T) { t.Fatalf("guarded replay = %+v replayed=%v err=%v", replay, replayed, err) } - before, err := service.App.CountRecords("payments") + before, err := app.CountRecords("payments") if err != nil { t.Fatal(err) } - _, replayed, err = service.CreateGuarded(CreateInput{AmountRupees: 26, IdempotencyKey: "guarded-denied"}, func(core.App) error { + _, replayed, err = service.CreateGuarded(CreateInput{AmountRupees: 26, IdempotencyKey: "guarded-denied"}, func(store.UnitOfWork) error { return domain.New("PAYMENT_ACCOUNT_UNAVAILABLE", "verification unavailable", 503) }) if err == nil || replayed { t.Fatalf("denied guarded create err=%v replayed=%v", err, replayed) } - after, err := service.App.CountRecords("payments") + after, err := app.CountRecords("payments") if err != nil { t.Fatal(err) } @@ -167,7 +168,7 @@ func TestCreateAllocatesAllNinetyNineSlotsAndExhausts(t *testing.T) { } func TestCreateIdempotencyAndExactPaymentMatching(t *testing.T) { - service, _, now := paymentTestService(t) + service, app, now := paymentTestService(t) first, replayed, err := service.Create(CreateInput{ AmountRupees: 100, ExternalID: "order-1", @@ -202,7 +203,7 @@ func TestCreateIdempotencyAndExactPaymentMatching(t *testing.T) { } // A freshly constructed service still reads the durable record state. - restarted := NewService(service.App, service.Config, nil) + restarted := NewService(app, service.Config, nil) restarted.Now = func() time.Time { return *now } persisted, err := restarted.Get(first.ID) if err != nil || persisted.Status != domain.StatusPaid || persisted.PayablePaise != first.PayablePaise { @@ -261,12 +262,7 @@ func TestConcurrentAllocationsRemainUnique(t *testing.T) { } func TestPublicPaymentRedactsEvidence(t *testing.T) { - record := core.NewRecord(core.NewBaseCollection("payments")) - record.Id = "payment-id" - record.Set("requested_amount", 10000) - record.Set("payable_amount", 10001) - record.Set("status", "paid") - payment := FromRecord(record) + payment := &domain.Payment{ID: "payment-id", RequestedPaise: 10000, PayablePaise: 10001, Status: domain.StatusPaid} public := PublicPayment(payment) for _, forbidden := range []string{"rrn", "upiId", "payerName", "rawSms"} { if _, ok := public[forbidden]; ok { @@ -469,12 +465,12 @@ func TestManualMatchKeepsExactAmountAndRRNInvariants(t *testing.T) { if err != nil { t.Fatal(err) } - var matched *core.Record - err = service.App.RunInTransaction(func(tx core.App) error { + var matched *domain.Payment + err = service.Store.Write(context.Background(), func(uow store.UnitOfWork) error { var action string var queued bool var matchErr error - matched, action, queued, matchErr = service.ManualMatchInApp(tx, payment.ID, domain.ParsedSMS{ + matched, action, queued, matchErr = service.ManualMatch(uow, payment.ID, domain.ParsedSMS{ AmountPaise: payment.PayablePaise, RRN: "800800800800", OccurredAt: *now, @@ -490,16 +486,16 @@ func TestManualMatchKeepsExactAmountAndRRNInvariants(t *testing.T) { if err != nil { t.Fatal(err) } - if matched.GetString("status") != "paid" { - t.Fatalf("status=%s", matched.GetString("status")) + if matched.Status != domain.StatusPaid { + t.Fatalf("status=%s", matched.Status) } other, _, err := service.Create(CreateInput{AmountRupees: 801}) if err != nil { t.Fatal(err) } - err = service.App.RunInTransaction(func(tx core.App) error { - _, _, _, err := service.ManualMatchInApp(tx, other.ID, domain.ParsedSMS{ + err = service.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + _, _, _, err := service.ManualMatch(uow, other.ID, domain.ParsedSMS{ AmountPaise: other.PayablePaise, RRN: "800800800800", }, *now) @@ -545,11 +541,11 @@ func TestManualMatchUsesEvidenceTimeForHistoricalReconciliation(t *testing.T) { if _, err := service.ExpireDue(); err != nil { t.Fatal(err) } - var matched *core.Record - err = service.App.RunInTransaction(func(tx core.App) error { + var matched *domain.Payment + err = service.Store.Write(context.Background(), func(uow store.UnitOfWork) error { var action string var matchErr error - matched, action, _, matchErr = service.ManualMatchInApp(tx, payment.ID, domain.ParsedSMS{ + matched, action, _, matchErr = service.ManualMatch(uow, payment.ID, domain.ParsedSMS{ AmountPaise: payment.PayablePaise, RRN: "850850850850", OccurredAt: evidenceAt, @@ -562,8 +558,8 @@ func TestManualMatchUsesEvidenceTimeForHistoricalReconciliation(t *testing.T) { if err != nil { t.Fatalf("historical evidence inside original quarantine was rejected: %v", err) } - if matched.GetString("status") != "late" { - t.Fatalf("status=%s", matched.GetString("status")) + if matched.Status != domain.StatusLate { + t.Fatalf("status=%s", matched.Status) } } @@ -574,8 +570,8 @@ func TestManualMatchRejectsTransactionAfterFingerprintReuseBoundary(t *testing.T t.Fatal(err) } *now = payment.ReuseAfter.Add(2 * time.Hour) - err = service.App.RunInTransaction(func(tx core.App) error { - _, _, _, err := service.ManualMatchInApp(tx, payment.ID, domain.ParsedSMS{ + err = service.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + _, _, _, err := service.ManualMatch(uow, payment.ID, domain.ParsedSMS{ AmountPaise: payment.PayablePaise, RRN: "851851851851", OccurredAt: payment.ReuseAfter.Add(time.Minute), diff --git a/internal/paytmnotification/service.go b/internal/paytmnotification/service.go index 853bb0f..b116b66 100644 --- a/internal/paytmnotification/service.go +++ b/internal/paytmnotification/service.go @@ -1,6 +1,7 @@ package paytmnotification import ( + "context" "database/sql" "errors" "strings" @@ -9,7 +10,7 @@ import ( "github.com/Phloraxx/payment-api/internal/domain" "github.com/Phloraxx/payment-api/internal/payments" - "github.com/pocketbase/dbx" + "github.com/Phloraxx/payment-api/internal/store" "github.com/pocketbase/pocketbase/core" ) @@ -37,21 +38,21 @@ type Result struct { } type Service struct { - App core.App + Store store.Database Payments *payments.Service Now func() time.Time } func NewService(app core.App, paymentService *payments.Service) *Service { - return &Service{App: app, Payments: paymentService, Now: time.Now} + return &Service{Store: store.NewPocketBase(app), Payments: paymentService, Now: time.Now} } func (s *Service) Ingest(input Input) (Result, error) { var result Result var queued bool - err := s.App.RunInTransaction(func(tx core.App) error { + err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { var err error - result, queued, err = s.IngestInApp(tx, input) + result, queued, err = s.IngestUoW(uow, input) return err }) if err != nil { @@ -63,10 +64,10 @@ func (s *Service) Ingest(input Input) (Result, error) { return result, nil } -// IngestInApp performs the Paytm evidence write and payment match in the caller's transaction. -// The returned queued flag tells the caller to wake outgoing webhooks only after its outer -// transaction has committed. -func (s *Service) IngestInApp(app core.App, input Input) (Result, bool, error) { +// IngestUoW persists Paytm evidence and applies matching in the caller's +// transaction. Android relay uses this to keep the relay event and payment +// mutation atomic with the downstream notification record. +func (s *Service) IngestUoW(uow store.UnitOfWork, input Input) (Result, bool, error) { input.Source = strings.TrimSpace(input.Source) if input.Source == "" { input.Source = "macrodroid" @@ -99,127 +100,77 @@ func (s *Service) IngestInApp(app core.App, input Input) (Result, bool, error) { notificationTime = now } - existing, err := app.FindFirstRecordByFilter("notification_events", "source = {:source} && source_event_id = {:id}", dbx.Params{"source": input.Source, "id": input.SourceEventID}) - var event *core.Record - if err == nil { - if existing.GetString("processing_status") != "unmatched" { - result := resultFromEvent(existing) + events := uow.NotificationEvents() + event, findErr := events.FindBySourceEvent(input.Source, input.SourceEventID) + if findErr == nil { + if event.ProcessingStatus != "unmatched" { + result := resultFromEvent(event) result.Action = "duplicate_event" result.Duplicate = true return result, false, nil } - event = existing - event.Set("processing_status", "received") - event.Set("error", "") - } else { - if !errors.Is(err, sql.ErrNoRows) { + event.ProcessingStatus, event.Error = "received", "" + if err := events.Save(event); err != nil { return Result{}, false, err } - collection, err := app.FindCollectionByNameOrId("notification_events") - if err != nil { - return Result{}, false, err + } else { + if !errors.Is(findErr, sql.ErrNoRows) { + return Result{}, false, findErr } - event = core.NewRecord(collection) - event.Set("source", input.Source) - event.Set("source_event_id", input.SourceEventID) - event.Set("app_package", input.AppPackage) - event.Set("app_name", input.AppName) - event.Set("title", input.Title) - event.Set("body", input.Body) - event.Set("big_text", input.BigText) - event.Set("channel", input.Channel) - event.Set("notification_time", notificationTime) - event.Set("payment_account", string(domain.PaymentAccountPaytm)) - event.Set("processing_status", "received") - if input.RawPayload != nil { - event.Set("raw_payload", input.RawPayload) + event = &domain.NotificationEvent{Source: input.Source, SourceEventID: input.SourceEventID, AppPackage: input.AppPackage, AppName: input.AppName, Title: input.Title, Body: input.Body, BigText: input.BigText, Channel: input.Channel, NotificationTime: notificationTime, Account: domain.PaymentAccountPaytm, ProcessingStatus: "received", RawPayload: input.RawPayload} + if err := events.Create(event); err != nil { + return Result{}, false, err } } - if err := app.Save(event); err != nil { - return Result{}, false, err - } - result := Result{EventID: event.Id} - + result := Result{EventID: event.ID} combined := strings.TrimSpace(strings.Join([]string{input.Title, input.Body, input.BigText}, "\n")) parsed, parseErr := Parse(combined) if errors.Is(parseErr, ErrUnrecognized) { - event.Set("processing_status", "ignored") - event.Set("error", "not a recognized Paytm customer-payment notification") - if err := app.Save(event); err != nil { + event.ProcessingStatus, event.Error = "ignored", "not a recognized Paytm customer-payment notification" + if err := events.Save(event); err != nil { return Result{}, false, err } - result.Status = "ignored" - result.Action = "ignored_non_payment_notification" + result.Status, result.Action = "ignored", "ignored_non_payment_notification" return result, false, nil } if parseErr != nil { - event.Set("processing_status", "error") - event.Set("error", parseErr.Error()) - if err := app.Save(event); err != nil { + event.ProcessingStatus, event.Error = "error", parseErr.Error() + if err := events.Save(event); err != nil { return Result{}, false, err } - result.Status = "error" - result.Action = "parse_error" + result.Status, result.Action = "error", "parse_error" return result, false, nil } - event.Set("amount", parsed.AmountPaise) - event.Set("payer_name", parsed.PayerName) - event.Set("processing_status", "parsed") - occurredAt := notificationTime - occurredUntil := notificationTime + event.AmountPaise, event.PayerName, event.ProcessingStatus = parsed.AmountPaise, parsed.PayerName, "parsed" + occurredAt, occurredUntil := notificationTime, notificationTime if !parsed.OccurredAt.IsZero() && !parsed.OccurredAt.After(now.Add(5*time.Minute)) { minuteStart := parsed.OccurredAt.UTC() minuteEnd := minuteStart.Add(time.Minute).Add(-time.Nanosecond) - if !notificationTime.Before(minuteStart) && !notificationTime.After(minuteEnd) { - // Paytm only prints minutes. Android's notification timestamp supplies - // precise seconds when it agrees with that displayed minute. - occurredAt = notificationTime - occurredUntil = notificationTime - } else { - // For delayed/offline delivery, preserve the full minute interval so a - // checkout created later in the same displayed minute remains eligible. - occurredAt = minuteStart - occurredUntil = minuteEnd + if notificationTime.Before(minuteStart) || notificationTime.After(minuteEnd) { + occurredAt, occurredUntil = minuteStart, minuteEnd } } - - payment, action, queued, matchErr := s.Payments.MatchNotificationInApp(app, payments.NotificationEvidence{ - Account: domain.PaymentAccountPaytm, AmountPaise: parsed.AmountPaise, PayerName: parsed.PayerName, - OccurredAt: occurredAt, OccurredUntil: occurredUntil, Reference: "paytm-notification:" + input.SourceEventID, - }, now) + payment, action, queued, matchErr := s.Payments.MatchNotification(uow, payments.NotificationEvidence{Account: domain.PaymentAccountPaytm, AmountPaise: parsed.AmountPaise, PayerName: parsed.PayerName, OccurredAt: occurredAt, OccurredUntil: occurredUntil, Reference: "paytm-notification:" + input.SourceEventID}, now) if matchErr != nil { - event.Set("processing_status", "error") - event.Set("error", matchErr.Error()) - if err := app.Save(event); err != nil { + event.ProcessingStatus, event.Error = "error", matchErr.Error() + if err := events.Save(event); err != nil { return Result{}, false, err } - result.Status = "error" - result.Action = "match_error" + result.Status, result.Action = "error", "match_error" return result, false, nil } switch action { case "marked_paid", "marked_late": - event.Set("processing_status", "matched") - event.Set("matched_payment", payment.Id) - result.Status = "matched" - result.PaymentID = payment.Id + event.ProcessingStatus, event.MatchedPaymentID, result.Status, result.PaymentID = "matched", payment.ID, "matched", payment.ID case "duplicate_evidence": - event.Set("processing_status", "duplicate") - event.Set("matched_payment", payment.Id) - result.Status = "duplicate" - result.PaymentID = payment.Id - result.Duplicate = true + event.ProcessingStatus, event.MatchedPaymentID, result.Status, result.PaymentID, result.Duplicate = "duplicate", payment.ID, "duplicate", payment.ID, true case "unmatched": - event.Set("processing_status", "unmatched") - event.Set("error", "no eligible Paytm payment has this exact amount") - result.Status = "unmatched" + event.ProcessingStatus, event.Error, result.Status = "unmatched", "no eligible Paytm payment has this exact amount", "unmatched" default: - event.Set("processing_status", "error") - event.Set("error", "unexpected matching action: "+action) - result.Status = "error" + event.ProcessingStatus, event.Error, result.Status = "error", "unexpected matching action: "+action, "error" } result.Action = action - if err := app.Save(event); err != nil { + if err := events.Save(event); err != nil { return Result{}, false, err } return result, queued, nil @@ -230,36 +181,36 @@ func (s *Service) RetryEvent(eventID string) (Result, error) { if eventID == "" { return Result{}, domain.New("INVALID_NOTIFICATION_EVENT_ID", "notification event id is required", 400) } - event, err := s.App.FindRecordById("notification_events", eventID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return Result{}, domain.New("NOTIFICATION_EVENT_NOT_FOUND", "notification event was not found", 404) + var input Input + err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + event, err := uow.NotificationEvents().Get(eventID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.New("NOTIFICATION_EVENT_NOT_FOUND", "notification event was not found", 404) + } + return err } - return Result{}, err - } - status := event.GetString("processing_status") - if status != "unmatched" && status != "error" { - return Result{}, domain.New("NOTIFICATION_EVENT_NOT_RETRYABLE", "only unmatched or failed notification events can be retried", 409) - } - if event.GetString("matched_payment") != "" { - return Result{}, domain.New("NOTIFICATION_EVENT_NOT_RETRYABLE", "matched notification events cannot be retried", 409) - } - if status == "error" { - event.Set("processing_status", "unmatched") - event.Set("error", "") - if err := s.App.Save(event); err != nil { - return Result{}, err + if event.ProcessingStatus != "unmatched" && event.ProcessingStatus != "error" { + return domain.New("NOTIFICATION_EVENT_NOT_RETRYABLE", "only unmatched or failed notification events can be retried", 409) } - } - return s.Ingest(Input{ - Source: event.GetString("source"), SourceEventID: event.GetString("source_event_id"), - AppPackage: event.GetString("app_package"), AppName: event.GetString("app_name"), - Title: event.GetString("title"), Body: event.GetString("body"), BigText: event.GetString("big_text"), - Channel: event.GetString("channel"), NotificationTime: event.GetDateTime("notification_time").Time(), - RawPayload: event.Get("raw_payload"), + if event.MatchedPaymentID != "" { + return domain.New("NOTIFICATION_EVENT_NOT_RETRYABLE", "matched notification events cannot be retried", 409) + } + if event.ProcessingStatus == "error" { + event.ProcessingStatus, event.Error = "unmatched", "" + if err := uow.NotificationEvents().Save(event); err != nil { + return err + } + } + input = Input{Source: event.Source, SourceEventID: event.SourceEventID, AppPackage: event.AppPackage, AppName: event.AppName, Title: event.Title, Body: event.Body, BigText: event.BigText, Channel: event.Channel, NotificationTime: event.NotificationTime, RawPayload: event.RawPayload} + return nil }) + if err != nil { + return Result{}, err + } + return s.Ingest(input) } -func resultFromEvent(event *core.Record) Result { - return Result{EventID: event.Id, Status: event.GetString("processing_status"), PaymentID: event.GetString("matched_payment")} +func resultFromEvent(event *domain.NotificationEvent) Result { + return Result{EventID: event.ID, Status: event.ProcessingStatus, PaymentID: event.MatchedPaymentID} } diff --git a/internal/razorpaycore/client.go b/internal/razorpaycore/client.go new file mode 100644 index 0000000..8031613 --- /dev/null +++ b/internal/razorpaycore/client.go @@ -0,0 +1,169 @@ +package razorpaycore + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const productionAPIBaseURL = "https://api.razorpay.com/v1" +const maxProviderResponseBytes = 1 << 20 + +type Client struct { + KeyID string + KeySecret string + HTTP *http.Client + BaseURL string + SourceTag string +} + +type ProviderOrder struct { + ID string `json:"id"` + Entity string `json:"entity"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + Receipt string `json:"receipt"` + Status string `json:"status"` +} + +type ProviderPayment struct { + ID string `json:"id"` + Entity string `json:"entity"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` + OrderID string `json:"order_id"` + Method string `json:"method"` + AmountRefunded int64 `json:"amount_refunded"` + Captured bool `json:"captured"` + ErrorCode string `json:"error_code"` + ErrorDescription string `json:"error_description"` +} + +func NewClient(keyID, keySecret, sourceTag string) *Client { + return &Client{ + KeyID: keyID, KeySecret: keySecret, + HTTP: &http.Client{ + Timeout: 12 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + BaseURL: productionAPIBaseURL, SourceTag: sourceTag, + } +} + +func (c *Client) CreateOrder(ctx context.Context, amountPaise int64, receipt string) (ProviderOrder, error) { + payload := map[string]any{ + "amount": amountPaise, "currency": "INR", "receipt": receipt, + "notes": map[string]string{"source": c.sourceTag()}, + } + var order ProviderOrder + if err := c.doJSON(ctx, http.MethodPost, "/orders", payload, &order); err != nil { + return ProviderOrder{}, err + } + if !strings.HasPrefix(order.ID, "order_") || order.Amount != amountPaise || !strings.EqualFold(order.Currency, "INR") { + return ProviderOrder{}, errors.New("razorpay returned an inconsistent order") + } + return order, nil +} + +func (c *Client) FetchPayment(ctx context.Context, paymentID string) (ProviderPayment, error) { + if !strings.HasPrefix(paymentID, "pay_") { + return ProviderPayment{}, errors.New("invalid razorpay payment id") + } + var payment ProviderPayment + if err := c.doJSON(ctx, http.MethodGet, "/payments/"+paymentID, nil, &payment); err != nil { + return ProviderPayment{}, err + } + if payment.ID != paymentID { + return ProviderPayment{}, errors.New("razorpay returned an inconsistent payment id") + } + return payment, nil +} + +func (c *Client) doJSON(ctx context.Context, method, path string, requestBody any, responseBody any) error { + var body io.Reader + if requestBody != nil { + raw, err := json.Marshal(requestBody) + if err != nil { + return err + } + body = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, c.apiBaseURL()+path, body) + if err != nil { + return err + } + req.SetBasicAuth(c.KeyID, c.KeySecret) + req.Header.Set("Accept", "application/json") + if requestBody != nil { + req.Header.Set("Content-Type", "application/json") + } + res, err := c.httpClient().Do(req) + if err != nil { + return fmt.Errorf("razorpay request failed: %w", err) + } + defer res.Body.Close() + raw, err := io.ReadAll(io.LimitReader(res.Body, maxProviderResponseBytes+1)) + if err != nil { + return fmt.Errorf("read razorpay response: %w", err) + } + if len(raw) > maxProviderResponseBytes { + return errors.New("razorpay response exceeded 1 MiB") + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("razorpay returned HTTP %d: %s", res.StatusCode, providerErrorMessage(raw)) + } + if err := json.Unmarshal(raw, responseBody); err != nil { + return fmt.Errorf("decode razorpay response: %w", err) + } + return nil +} + +func (c *Client) apiBaseURL() string { + if c.BaseURL != "" { + return strings.TrimRight(c.BaseURL, "/") + } + return productionAPIBaseURL +} + +func (c *Client) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return NewClient(c.KeyID, c.KeySecret, c.SourceTag).HTTP +} + +func (c *Client) sourceTag() string { + if strings.TrimSpace(c.SourceTag) != "" { + return strings.TrimSpace(c.SourceTag) + } + return "paygate_razorpay" +} + +func providerErrorMessage(raw []byte) string { + var envelope struct { + Error struct { + Code string `json:"code"` + Description string `json:"description"` + } `json:"error"` + } + if json.Unmarshal(raw, &envelope) == nil && envelope.Error.Description != "" { + return envelope.Error.Description + } + text := strings.TrimSpace(string(raw)) + if len(text) > 512 { + text = text[:512] + } + if text == "" { + return "empty error response" + } + return text +} diff --git a/internal/razorpaycore/mode_test.go b/internal/razorpaycore/mode_test.go new file mode 100644 index 0000000..470737b --- /dev/null +++ b/internal/razorpaycore/mode_test.go @@ -0,0 +1,19 @@ +package razorpaycore + +import "testing" + +func TestModesRemainStorageAndIdentityIsolated(t *testing.T) { + testMode := TestMode() + liveMode := LiveMode() + if testMode.OrdersCollection == liveMode.OrdersCollection || + testMode.EventsCollection == liveMode.EventsCollection || + testMode.EventOrderField == liveMode.EventOrderField || + testMode.ReceiptPrefix == liveMode.ReceiptPrefix || + testMode.ErrorPrefix == liveMode.ErrorPrefix { + t.Fatalf("Razorpay test/live mode identities must remain isolated: test=%+v live=%+v", testMode, liveMode) + } + if testMode.MinOrderPaise <= 0 || liveMode.MinOrderPaise <= 0 || + testMode.MaxOrderPaise < testMode.MinOrderPaise || liveMode.MaxOrderPaise < liveMode.MinOrderPaise { + t.Fatalf("invalid Razorpay amount policy: test=%+v live=%+v", testMode, liveMode) + } +} diff --git a/internal/razorpaycore/service.go b/internal/razorpaycore/service.go new file mode 100644 index 0000000..9b586dc --- /dev/null +++ b/internal/razorpaycore/service.go @@ -0,0 +1,501 @@ +package razorpaycore + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/pocketbase/pocketbase/core" +) + +const maxWebhookBytes = 1 << 20 + +type Mode struct { + Name string + ErrorPrefix string + OrdersCollection string + EventsCollection string + EventOrderField string + ReceiptPrefix string + DashboardName string + MinOrderPaise int64 + MaxOrderPaise int64 +} + +func TestMode() Mode { + return Mode{ + Name: "test", ErrorPrefix: "RAZORPAY_TEST", + OrdersCollection: "razorpay_test_orders", EventsCollection: "razorpay_test_events", + EventOrderField: "test_order", ReceiptPrefix: "pgt_", DashboardName: "Razorpay Test Dashboard", + MinOrderPaise: 100, MaxOrderPaise: 100_000_00, + } +} + +func LiveMode() Mode { + return Mode{ + Name: "live", ErrorPrefix: "RAZORPAY_LIVE", + OrdersCollection: "razorpay_live_orders", EventsCollection: "razorpay_live_events", + EventOrderField: "live_order", ReceiptPrefix: "pgl_", DashboardName: "Razorpay Live Dashboard", + MinOrderPaise: 100, MaxOrderPaise: 100_000_00, + } +} + +type ProviderClient interface { + CreateOrder(ctx context.Context, amountPaise int64, receipt string) (ProviderOrder, error) + FetchPayment(ctx context.Context, paymentID string) (ProviderPayment, error) +} + +type Service struct { + App core.App + Mode Mode + Client ProviderClient + KeyID string + KeySecret string + WebhookSecret string + DisplayName string + Now func() time.Time +} + +type CreateInput struct { + AmountPaise int64 + ExternalID string + IdempotencyKey string + ActorID string +} + +type VerifyInput struct { + LocalOrderID string + RazorpayOrderID string + RazorpayPaymentID string + RazorpaySignature string +} + +type WebhookResult struct { + Duplicate bool `json:"duplicate"` + Processed bool `json:"processed"` + Ignored bool `json:"ignored"` + EventID string `json:"eventId"` + OrderID string `json:"orderId,omitempty"` + Status string `json:"status,omitempty"` +} + +type webhookEnvelope struct { + Event string `json:"event"` + CreatedAt int64 `json:"created_at"` + Payload struct { + Payment struct { + Entity ProviderPayment `json:"entity"` + } `json:"payment"` + } `json:"payload"` +} + +func NewService(app core.App, client ProviderClient, keyID, keySecret, webhookSecret, displayName string, mode Mode) *Service { + return &Service{ + App: app, Mode: mode, Client: client, KeyID: keyID, KeySecret: keySecret, + WebhookSecret: webhookSecret, DisplayName: displayName, Now: time.Now, + } +} + +func (s *Service) Create(ctx context.Context, input CreateInput) (*core.Record, bool, error) { + input.ExternalID = strings.TrimSpace(input.ExternalID) + input.IdempotencyKey = strings.TrimSpace(input.IdempotencyKey) + if input.AmountPaise < s.Mode.MinOrderPaise || input.AmountPaise > s.Mode.MaxOrderPaise { + return nil, false, domain.New(s.code("INVALID_AMOUNT"), s.Mode.Name+" amount must be between ₹1 and ₹1,00,000", 400) + } + if input.IdempotencyKey == "" || len(input.IdempotencyKey) > 255 { + return nil, false, domain.New(s.code("IDEMPOTENCY_REQUIRED"), "a valid Idempotency-Key is required", 400) + } + if len(input.ExternalID) > 255 { + return nil, false, domain.InvalidExternalID() + } + + if existing, err := s.App.FindFirstRecordByData(s.Mode.OrdersCollection, "idempotency_key", input.IdempotencyKey); err == nil { + if int64(existing.GetInt("amount")) != input.AmountPaise || existing.GetString("external_id") != input.ExternalID { + return nil, false, domain.New(s.code("IDEMPOTENCY_CONFLICT"), "the idempotency key was already used with different parameters", 409) + } + if status := existing.GetString("status"); status == "creating" || status == "create_failed" { + domainErr := domain.New(s.code("CREATE_STATE_UNKNOWN"), "the previous provider-order attempt did not complete cleanly; inspect the "+s.Mode.DashboardName+" using the local receipt before starting a new attempt", 409) + domainErr.Details = map[string]any{"localOrderId": existing.Id, "receipt": "pgt_" + existing.Id, "status": status} + return nil, false, domainErr + } + return existing, true, nil + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, false, err + } + + collection, err := s.App.FindCollectionByNameOrId(s.Mode.OrdersCollection) + if err != nil { + return nil, false, err + } + now := s.now() + record := core.NewRecord(collection) + record.Set("amount", input.AmountPaise) + record.Set("currency", "INR") + record.Set("status", "creating") + record.Set("external_id", input.ExternalID) + record.Set("idempotency_key", input.IdempotencyKey) + record.Set("created_by", input.ActorID) + record.Set("created_at", now) + if err := s.App.Save(record); err != nil { + if existing, findErr := s.App.FindFirstRecordByData(s.Mode.OrdersCollection, "idempotency_key", input.IdempotencyKey); findErr == nil { + return existing, true, nil + } + return nil, false, err + } + + providerOrder, err := s.Client.CreateOrder(ctx, input.AmountPaise, s.Mode.ReceiptPrefix+record.Id) + if err != nil { + record.Set("status", "create_failed") + record.Set("error", truncate(err.Error(), 4096)) + record.Set("last_synced_at", now) + _ = s.App.Save(record) + domainErr := domain.New(s.code("CREATE_FAILED"), "Razorpay "+s.Mode.Name+" order creation failed", 502) + domainErr.Details = map[string]any{"localOrderId": record.Id} + return record, false, domainErr + } + record.Set("razorpay_order_id", providerOrder.ID) + record.Set("provider_status", providerOrder.Status) + record.Set("status", "created") + record.Set("error", "") + record.Set("last_synced_at", now) + if err := s.App.Save(record); err != nil { + return nil, false, err + } + return record, false, nil +} + +func (s *Service) Get(localOrderID string) (*core.Record, error) { + record, err := s.App.FindRecordById(s.Mode.OrdersCollection, strings.TrimSpace(localOrderID)) + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.New(s.code("ORDER_NOT_FOUND"), "Razorpay "+s.Mode.Name+" order not found", 404) + } + return record, err +} + +func (s *Service) Verify(ctx context.Context, input VerifyInput) (*core.Record, error) { + record, err := s.Get(input.LocalOrderID) + if err != nil { + return nil, err + } + providerOrderID := record.GetString("razorpay_order_id") + if providerOrderID == "" || input.RazorpayOrderID != providerOrderID { + return nil, domain.New(s.code("ORDER_MISMATCH"), "checkout order id does not match the server-created order", 400) + } + if !strings.HasPrefix(input.RazorpayPaymentID, "pay_") { + return nil, domain.New(s.code("INVALID_PAYMENT"), "invalid Razorpay payment id", 400) + } + if !verifyHexHMAC(s.KeySecret, providerOrderID+"|"+input.RazorpayPaymentID, input.RazorpaySignature) { + return nil, domain.New(s.code("SIGNATURE_INVALID"), "Razorpay checkout signature verification failed", 400) + } + + err = s.App.RunInTransaction(func(tx core.App) error { + current, err := tx.FindRecordById(s.Mode.OrdersCollection, record.Id) + if err != nil { + return err + } + if existing := current.GetString("razorpay_payment_id"); existing != "" && existing != input.RazorpayPaymentID { + return domain.New(s.code("PAYMENT_CONFLICT"), "the order is already linked to another Razorpay payment", 409) + } + if other, findErr := tx.FindFirstRecordByData(s.Mode.OrdersCollection, "razorpay_payment_id", input.RazorpayPaymentID); findErr == nil && other.Id != current.Id { + return domain.New(s.code("PAYMENT_CONFLICT"), "the Razorpay payment is already linked to another "+s.Mode.Name+" order", 409) + } else if findErr != nil && !errors.Is(findErr, sql.ErrNoRows) { + return findErr + } + current.Set("razorpay_payment_id", input.RazorpayPaymentID) + current.Set("signature_verified_at", s.now()) + if current.GetString("status") != "captured" && current.GetString("status") != "refunded" && current.GetString("status") != "partially_refunded" { + current.Set("status", "verification_pending") + } + return tx.Save(current) + }) + if err != nil { + return nil, err + } + + // A signed browser callback proves authenticity, not capture. Fetch the + // provider state immediately for responsive test UX; webhooks remain the + // authoritative asynchronous path if this fetch fails. + if _, refreshErr := s.Refresh(ctx, record.Id); refreshErr != nil { + var domainErr *domain.Error + if errors.As(refreshErr, &domainErr) && domainErr.Code != s.code("REFRESH_FAILED") { + return nil, refreshErr + } + return s.Get(record.Id) + } + return s.Get(record.Id) +} + +func (s *Service) Refresh(ctx context.Context, localOrderID string) (*core.Record, error) { + record, err := s.Get(localOrderID) + if err != nil { + return nil, err + } + paymentID := record.GetString("razorpay_payment_id") + if paymentID == "" { + return nil, domain.New(s.code("PAYMENT_UNKNOWN"), "no Razorpay payment id is linked to this order yet", 409) + } + payment, err := s.Client.FetchPayment(ctx, paymentID) + if err != nil { + return nil, domain.New(s.code("REFRESH_FAILED"), "could not fetch the Razorpay payment", 502) + } + if err := s.applyPayment(record.Id, payment, s.now()); err != nil { + return nil, err + } + return s.Get(record.Id) +} + +func (s *Service) IngestWebhook(eventID, signature string, raw []byte) (WebhookResult, error) { + eventID = strings.TrimSpace(eventID) + if eventID == "" || len(eventID) > 128 { + return WebhookResult{}, domain.New(s.code("EVENT_ID_REQUIRED"), "X-Razorpay-Event-Id is required", 400) + } + if len(raw) == 0 || len(raw) > maxWebhookBytes { + return WebhookResult{}, domain.New(s.code("WEBHOOK_INVALID"), "webhook body must be between 1 byte and 1 MiB", 400) + } + if !verifyHexHMAC(s.WebhookSecret, string(raw), signature) { + return WebhookResult{}, domain.New(s.code("WEBHOOK_SIGNATURE_INVALID"), "invalid Razorpay webhook signature", 401) + } + hashBytes := sha256.Sum256(raw) + payloadHash := hex.EncodeToString(hashBytes[:]) + if existing, err := s.App.FindFirstRecordByData(s.Mode.EventsCollection, "event_id", eventID); err == nil { + if existing.GetString("payload_hash") != payloadHash { + return WebhookResult{}, domain.New(s.code("EVENT_ID_CONFLICT"), "the Razorpay event id was already used with a different payload", 409) + } + return WebhookResult{Duplicate: true, EventID: eventID, OrderID: existing.GetString(s.Mode.EventOrderField), Status: existing.GetString("status")}, nil + } else if !errors.Is(err, sql.ErrNoRows) { + return WebhookResult{}, err + } + + var envelope webhookEnvelope + if err := json.Unmarshal(raw, &envelope); err != nil { + return WebhookResult{}, domain.New(s.code("WEBHOOK_INVALID"), "invalid Razorpay webhook JSON", 400) + } + payment := envelope.Payload.Payment.Entity + result := WebhookResult{EventID: eventID} + now := s.now() + err := s.App.RunInTransaction(func(tx core.App) error { + if existing, err := tx.FindFirstRecordByData(s.Mode.EventsCollection, "event_id", eventID); err == nil { + if existing.GetString("payload_hash") != payloadHash { + return domain.New(s.code("EVENT_ID_CONFLICT"), "the Razorpay event id was already used with a different payload", 409) + } + result.Duplicate = true + result.OrderID = existing.GetString(s.Mode.EventOrderField) + result.Status = existing.GetString("status") + return nil + } else if !errors.Is(err, sql.ErrNoRows) { + return err + } + collection, err := tx.FindCollectionByNameOrId(s.Mode.EventsCollection) + if err != nil { + return err + } + event := core.NewRecord(collection) + event.Set("event_id", eventID) + event.Set("event_type", truncate(envelope.Event, 128)) + event.Set("razorpay_order_id", payment.OrderID) + event.Set("razorpay_payment_id", payment.ID) + event.Set("payload_hash", payloadHash) + event.Set("received_at", now) + if envelope.CreatedAt > 0 { + event.Set("provider_created_at", time.Unix(envelope.CreatedAt, 0).UTC()) + } + + order, findErr := tx.FindFirstRecordByData(s.Mode.OrdersCollection, "razorpay_order_id", payment.OrderID) + if errors.Is(findErr, sql.ErrNoRows) { + event.Set("status", "ignored") + event.Set("error", "No local Razorpay "+s.Mode.Name+" order matches this event") + result.Ignored = true + result.Status = "ignored" + return tx.Save(event) + } + if findErr != nil { + return findErr + } + event.Set(s.Mode.EventOrderField, order.Id) + result.OrderID = order.Id + if envelope.Event != "payment.captured" && envelope.Event != "payment.failed" { + event.Set("status", "ignored") + result.Ignored = true + result.Status = "ignored" + return tx.Save(event) + } + if err := s.validateProviderPayment(order, payment); err != nil { + event.Set("status", "failed") + event.Set("error", truncate(err.Error(), 4096)) + result.Status = "failed" + return tx.Save(event) + } + if err := s.applyProviderPayment(order, payment, now); err != nil { + return err + } + if err := tx.Save(order); err != nil { + return err + } + event.Set("status", "processed") + result.Processed = true + result.Status = order.GetString("status") + return tx.Save(event) + }) + return result, err +} + +func (s *Service) applyPayment(localOrderID string, payment ProviderPayment, at time.Time) error { + return s.App.RunInTransaction(func(tx core.App) error { + order, err := tx.FindRecordById(s.Mode.OrdersCollection, localOrderID) + if err != nil { + return err + } + if err := s.validateProviderPayment(order, payment); err != nil { + return err + } + if err := s.applyProviderPayment(order, payment, at); err != nil { + return err + } + return tx.Save(order) + }) +} + +func (s *Service) validateProviderPayment(order *core.Record, payment ProviderPayment) error { + if payment.ID == "" || payment.OrderID != order.GetString("razorpay_order_id") { + return domain.New(s.code("PROVIDER_MISMATCH"), "Razorpay payment does not belong to the local order", 409) + } + if payment.Amount != int64(order.GetInt("amount")) || !strings.EqualFold(payment.Currency, order.GetString("currency")) { + return domain.New(s.code("PROVIDER_MISMATCH"), "Razorpay payment amount or currency does not match the local order", 409) + } + return nil +} + +func (s *Service) applyProviderPayment(order *core.Record, payment ProviderPayment, at time.Time) error { + if existing := order.GetString("razorpay_payment_id"); existing != "" && existing != payment.ID { + return domain.New(s.code("PAYMENT_CONFLICT"), "the local order is linked to another Razorpay payment", 409) + } + current := order.GetString("status") + next := localStatus(payment) + if !shouldApplyStatus(current, next) { + return nil + } + order.Set("razorpay_payment_id", payment.ID) + order.Set("payment_method", truncate(payment.Method, 64)) + order.Set("provider_status", truncate(payment.Status, 64)) + order.Set("amount_refunded", payment.AmountRefunded) + order.Set("last_synced_at", at) + order.Set("error", truncate(firstNonEmpty(payment.ErrorDescription, payment.ErrorCode), 4096)) + order.Set("status", next) + switch next { + case "captured": + order.Set("captured_at", at) + case "failed": + order.Set("failed_at", at) + } + return nil +} + +func localStatus(payment ProviderPayment) string { + if payment.AmountRefunded >= payment.Amount && payment.Amount > 0 { + return "refunded" + } + if payment.AmountRefunded > 0 { + return "partially_refunded" + } + switch payment.Status { + case "captured": + return "captured" + case "authorized": + return "authorized" + case "failed": + return "failed" + case "refunded": + return "refunded" + default: + return "verification_pending" + } +} + +func shouldApplyStatus(current, next string) bool { + if current == next { + return true + } + if current == "refunded" { + return false + } + if current == "partially_refunded" { + return next == "refunded" + } + if current == "captured" { + return next == "partially_refunded" || next == "refunded" + } + if next == "captured" || next == "partially_refunded" || next == "refunded" { + return true + } + if current == "failed" { + return false + } + return true +} + +func verifyHexHMAC(secret, message, provided string) bool { + providedBytes, err := hex.DecodeString(strings.TrimSpace(provided)) + if err != nil { + return false + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(message)) + expected := mac.Sum(nil) + return len(providedBytes) == len(expected) && subtle.ConstantTimeCompare(providedBytes, expected) == 1 +} + +func Sign(secret string, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +func OrderResponse(record *core.Record, keyID, displayName string) map[string]any { + if record == nil { + return nil + } + return map[string]any{ + "id": record.Id, "amountPaise": record.GetInt("amount"), "currency": record.GetString("currency"), + "status": record.GetString("status"), "externalId": record.GetString("external_id"), + "razorpayOrderId": record.GetString("razorpay_order_id"), "razorpayPaymentId": record.GetString("razorpay_payment_id"), + "providerStatus": record.GetString("provider_status"), "paymentMethod": record.GetString("payment_method"), + "amountRefunded": record.GetInt("amount_refunded"), "error": record.GetString("error"), + "createdAt": record.GetDateTime("created_at").String(), "capturedAt": record.GetDateTime("captured_at").String(), + "keyId": keyID, "displayName": displayName, + } +} + +func (s *Service) code(suffix string) string { + return s.Mode.ErrorPrefix + "_" + suffix +} + +func (s *Service) now() time.Time { + if s.Now == nil { + return time.Now().UTC() + } + return s.Now().UTC() +} + +func truncate(value string, max int) string { + if len(value) <= max { + return value + } + return value[:max] +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/internal/razorpaylive/client.go b/internal/razorpaylive/client.go index 92d2e82..8a0e31a 100644 --- a/internal/razorpaylive/client.go +++ b/internal/razorpaylive/client.go @@ -1,161 +1,11 @@ package razorpaylive -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" -) +import "github.com/Phloraxx/payment-api/internal/razorpaycore" -const productionAPIBaseURL = "https://api.razorpay.com/v1" -const maxProviderResponseBytes = 1 << 20 - -type Client struct { - KeyID string - KeySecret string - HTTP *http.Client - baseURL string -} - -type ProviderOrder struct { - ID string `json:"id"` - Entity string `json:"entity"` - Amount int64 `json:"amount"` - Currency string `json:"currency"` - Receipt string `json:"receipt"` - Status string `json:"status"` -} - -type ProviderPayment struct { - ID string `json:"id"` - Entity string `json:"entity"` - Amount int64 `json:"amount"` - Currency string `json:"currency"` - Status string `json:"status"` - OrderID string `json:"order_id"` - Method string `json:"method"` - AmountRefunded int64 `json:"amount_refunded"` - Captured bool `json:"captured"` - ErrorCode string `json:"error_code"` - ErrorDescription string `json:"error_description"` -} +type Client = razorpaycore.Client +type ProviderOrder = razorpaycore.ProviderOrder +type ProviderPayment = razorpaycore.ProviderPayment func NewClient(keyID, keySecret string) *Client { - return &Client{ - KeyID: keyID, KeySecret: keySecret, - HTTP: &http.Client{ - Timeout: 12 * time.Second, - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - }, - }, - baseURL: productionAPIBaseURL, - } -} - -func (c *Client) CreateOrder(ctx context.Context, amountPaise int64, receipt string) (ProviderOrder, error) { - payload := map[string]any{ - "amount": amountPaise, "currency": "INR", "receipt": receipt, - "notes": map[string]string{"source": "paygate_razorpay_live"}, - } - var order ProviderOrder - if err := c.doJSON(ctx, http.MethodPost, "/orders", payload, &order); err != nil { - return ProviderOrder{}, err - } - if !strings.HasPrefix(order.ID, "order_") || order.Amount != amountPaise || !strings.EqualFold(order.Currency, "INR") { - return ProviderOrder{}, errors.New("razorpay returned an inconsistent order") - } - return order, nil -} - -func (c *Client) FetchPayment(ctx context.Context, paymentID string) (ProviderPayment, error) { - if !strings.HasPrefix(paymentID, "pay_") { - return ProviderPayment{}, errors.New("invalid razorpay payment id") - } - var payment ProviderPayment - if err := c.doJSON(ctx, http.MethodGet, "/payments/"+paymentID, nil, &payment); err != nil { - return ProviderPayment{}, err - } - if payment.ID != paymentID { - return ProviderPayment{}, errors.New("razorpay returned an inconsistent payment id") - } - return payment, nil -} - -func (c *Client) doJSON(ctx context.Context, method, path string, requestBody any, responseBody any) error { - var body io.Reader - if requestBody != nil { - raw, err := json.Marshal(requestBody) - if err != nil { - return err - } - body = bytes.NewReader(raw) - } - req, err := http.NewRequestWithContext(ctx, method, c.apiBaseURL()+path, body) - if err != nil { - return err - } - req.SetBasicAuth(c.KeyID, c.KeySecret) - req.Header.Set("Accept", "application/json") - if requestBody != nil { - req.Header.Set("Content-Type", "application/json") - } - res, err := c.httpClient().Do(req) - if err != nil { - return fmt.Errorf("razorpay request failed: %w", err) - } - defer res.Body.Close() - raw, err := io.ReadAll(io.LimitReader(res.Body, maxProviderResponseBytes+1)) - if err != nil { - return fmt.Errorf("read razorpay response: %w", err) - } - if len(raw) > maxProviderResponseBytes { - return errors.New("razorpay response exceeded 1 MiB") - } - if res.StatusCode < 200 || res.StatusCode >= 300 { - return fmt.Errorf("razorpay returned HTTP %d: %s", res.StatusCode, providerErrorMessage(raw)) - } - if err := json.Unmarshal(raw, responseBody); err != nil { - return fmt.Errorf("decode razorpay response: %w", err) - } - return nil -} - -func (c *Client) apiBaseURL() string { - if c.baseURL != "" { - return strings.TrimRight(c.baseURL, "/") - } - return productionAPIBaseURL -} - -func (c *Client) httpClient() *http.Client { - if c.HTTP != nil { - return c.HTTP - } - return NewClient(c.KeyID, c.KeySecret).HTTP -} - -func providerErrorMessage(raw []byte) string { - var envelope struct { - Error struct { - Code string `json:"code"` - Description string `json:"description"` - } `json:"error"` - } - if json.Unmarshal(raw, &envelope) == nil && envelope.Error.Description != "" { - return envelope.Error.Description - } - text := strings.TrimSpace(string(raw)) - if len(text) > 512 { - text = text[:512] - } - if text == "" { - return "empty error response" - } - return text + return razorpaycore.NewClient(keyID, keySecret, "paygate_razorpay_live") } diff --git a/internal/razorpaylive/client_test.go b/internal/razorpaylive/client_test.go index a3ea584..c64b888 100644 --- a/internal/razorpaylive/client_test.go +++ b/internal/razorpaylive/client_test.go @@ -26,7 +26,7 @@ func TestClientCreatesOrderWithBasicAuthAndRefusesRedirects(t *testing.T) { })) defer server.Close() client := NewClient("rzp_live_key", "secret") - client.baseURL = server.URL + client.BaseURL = server.URL _, err := client.CreateOrder(context.Background(), 100, "receipt") if err == nil { t.Fatal("expected redirect response to be rejected") @@ -43,7 +43,7 @@ func TestClientValidatesProviderOrderAmount(t *testing.T) { })) defer server.Close() client := NewClient("rzp_live_key", "secret") - client.baseURL = server.URL + client.BaseURL = server.URL if _, err := client.CreateOrder(context.Background(), 100, "receipt"); err == nil { t.Fatal("expected inconsistent amount error") } diff --git a/internal/razorpaylive/service.go b/internal/razorpaylive/service.go index b60a940..fe779f6 100644 --- a/internal/razorpaylive/service.go +++ b/internal/razorpaylive/service.go @@ -1,470 +1,19 @@ package razorpaylive import ( - "context" - "crypto/hmac" - "crypto/sha256" - "crypto/subtle" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "strings" - "time" - - "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/razorpaycore" "github.com/pocketbase/pocketbase/core" ) -const ( - maxWebhookBytes = 1 << 20 - minOrderPaise = int64(100) - maxOrderPaise = int64(100_000_00) -) - -type ProviderClient interface { - CreateOrder(ctx context.Context, amountPaise int64, receipt string) (ProviderOrder, error) - FetchPayment(ctx context.Context, paymentID string) (ProviderPayment, error) -} - -type Service struct { - App core.App - Client ProviderClient - KeyID string - KeySecret string - WebhookSecret string - DisplayName string - Now func() time.Time -} - -type CreateInput struct { - AmountPaise int64 - ExternalID string - IdempotencyKey string - ActorID string -} - -type VerifyInput struct { - LocalOrderID string - RazorpayOrderID string - RazorpayPaymentID string - RazorpaySignature string -} - -type WebhookResult struct { - Duplicate bool `json:"duplicate"` - Processed bool `json:"processed"` - Ignored bool `json:"ignored"` - EventID string `json:"eventId"` - OrderID string `json:"orderId,omitempty"` - Status string `json:"status,omitempty"` -} - -type webhookEnvelope struct { - Event string `json:"event"` - CreatedAt int64 `json:"created_at"` - Payload struct { - Payment struct { - Entity ProviderPayment `json:"entity"` - } `json:"payment"` - } `json:"payload"` -} +type ProviderClient = razorpaycore.ProviderClient +type Service = razorpaycore.Service +type CreateInput = razorpaycore.CreateInput +type VerifyInput = razorpaycore.VerifyInput +type WebhookResult = razorpaycore.WebhookResult func NewService(app core.App, client ProviderClient, keyID, keySecret, webhookSecret, displayName string) *Service { - return &Service{ - App: app, Client: client, KeyID: keyID, KeySecret: keySecret, - WebhookSecret: webhookSecret, DisplayName: displayName, Now: time.Now, - } -} - -func (s *Service) Create(ctx context.Context, input CreateInput) (*core.Record, bool, error) { - input.ExternalID = strings.TrimSpace(input.ExternalID) - input.IdempotencyKey = strings.TrimSpace(input.IdempotencyKey) - if input.AmountPaise < minOrderPaise || input.AmountPaise > maxOrderPaise { - return nil, false, domain.New("RAZORPAY_LIVE_INVALID_AMOUNT", "live amount must be between ₹1 and ₹1,00,000", 400) - } - if input.IdempotencyKey == "" || len(input.IdempotencyKey) > 255 { - return nil, false, domain.New("RAZORPAY_LIVE_IDEMPOTENCY_REQUIRED", "a valid Idempotency-Key is required", 400) - } - if len(input.ExternalID) > 255 { - return nil, false, domain.InvalidExternalID() - } - - if existing, err := s.App.FindFirstRecordByData("razorpay_live_orders", "idempotency_key", input.IdempotencyKey); err == nil { - if int64(existing.GetInt("amount")) != input.AmountPaise || existing.GetString("external_id") != input.ExternalID { - return nil, false, domain.New("RAZORPAY_LIVE_IDEMPOTENCY_CONFLICT", "the idempotency key was already used with different parameters", 409) - } - if status := existing.GetString("status"); status == "creating" || status == "create_failed" { - domainErr := domain.New("RAZORPAY_LIVE_CREATE_STATE_UNKNOWN", "the previous provider-order attempt did not complete cleanly; inspect the Razorpay Live Dashboard using the local receipt before starting a new attempt", 409) - domainErr.Details = map[string]any{"localOrderId": existing.Id, "receipt": "pgl_" + existing.Id, "status": status} - return nil, false, domainErr - } - return existing, true, nil - } else if !errors.Is(err, sql.ErrNoRows) { - return nil, false, err - } - - collection, err := s.App.FindCollectionByNameOrId("razorpay_live_orders") - if err != nil { - return nil, false, err - } - now := s.now() - record := core.NewRecord(collection) - record.Set("amount", input.AmountPaise) - record.Set("currency", "INR") - record.Set("status", "creating") - record.Set("external_id", input.ExternalID) - record.Set("idempotency_key", input.IdempotencyKey) - record.Set("created_by", input.ActorID) - record.Set("created_at", now) - if err := s.App.Save(record); err != nil { - if existing, findErr := s.App.FindFirstRecordByData("razorpay_live_orders", "idempotency_key", input.IdempotencyKey); findErr == nil { - return existing, true, nil - } - return nil, false, err - } - - providerOrder, err := s.Client.CreateOrder(ctx, input.AmountPaise, "pgl_"+record.Id) - if err != nil { - record.Set("status", "create_failed") - record.Set("error", truncate(err.Error(), 4096)) - record.Set("last_synced_at", now) - _ = s.App.Save(record) - domainErr := domain.New("RAZORPAY_LIVE_CREATE_FAILED", "Razorpay live order creation failed", 502) - domainErr.Details = map[string]any{"localOrderId": record.Id} - return record, false, domainErr - } - record.Set("razorpay_order_id", providerOrder.ID) - record.Set("provider_status", providerOrder.Status) - record.Set("status", "created") - record.Set("error", "") - record.Set("last_synced_at", now) - if err := s.App.Save(record); err != nil { - return nil, false, err - } - return record, false, nil -} - -func (s *Service) Get(localOrderID string) (*core.Record, error) { - record, err := s.App.FindRecordById("razorpay_live_orders", strings.TrimSpace(localOrderID)) - if errors.Is(err, sql.ErrNoRows) { - return nil, domain.New("RAZORPAY_LIVE_ORDER_NOT_FOUND", "Razorpay live order not found", 404) - } - return record, err -} - -func (s *Service) Verify(ctx context.Context, input VerifyInput) (*core.Record, error) { - record, err := s.Get(input.LocalOrderID) - if err != nil { - return nil, err - } - providerOrderID := record.GetString("razorpay_order_id") - if providerOrderID == "" || input.RazorpayOrderID != providerOrderID { - return nil, domain.New("RAZORPAY_LIVE_ORDER_MISMATCH", "checkout order id does not match the server-created order", 400) - } - if !strings.HasPrefix(input.RazorpayPaymentID, "pay_") { - return nil, domain.New("RAZORPAY_LIVE_INVALID_PAYMENT", "invalid Razorpay payment id", 400) - } - if !verifyHexHMAC(s.KeySecret, providerOrderID+"|"+input.RazorpayPaymentID, input.RazorpaySignature) { - return nil, domain.New("RAZORPAY_LIVE_SIGNATURE_INVALID", "Razorpay checkout signature verification failed", 400) - } - - err = s.App.RunInTransaction(func(tx core.App) error { - current, err := tx.FindRecordById("razorpay_live_orders", record.Id) - if err != nil { - return err - } - if existing := current.GetString("razorpay_payment_id"); existing != "" && existing != input.RazorpayPaymentID { - return domain.New("RAZORPAY_LIVE_PAYMENT_CONFLICT", "the order is already linked to another Razorpay payment", 409) - } - if other, findErr := tx.FindFirstRecordByData("razorpay_live_orders", "razorpay_payment_id", input.RazorpayPaymentID); findErr == nil && other.Id != current.Id { - return domain.New("RAZORPAY_LIVE_PAYMENT_CONFLICT", "the Razorpay payment is already linked to another live order", 409) - } else if findErr != nil && !errors.Is(findErr, sql.ErrNoRows) { - return findErr - } - current.Set("razorpay_payment_id", input.RazorpayPaymentID) - current.Set("signature_verified_at", s.now()) - if current.GetString("status") != "captured" && current.GetString("status") != "refunded" && current.GetString("status") != "partially_refunded" { - current.Set("status", "verification_pending") - } - return tx.Save(current) - }) - if err != nil { - return nil, err - } - - // A signed browser callback proves authenticity, not capture. Fetch the - // provider state immediately for responsive test UX; webhooks remain the - // authoritative asynchronous path if this fetch fails. - if _, refreshErr := s.Refresh(ctx, record.Id); refreshErr != nil { - var domainErr *domain.Error - if errors.As(refreshErr, &domainErr) && domainErr.Code != "RAZORPAY_LIVE_REFRESH_FAILED" { - return nil, refreshErr - } - return s.Get(record.Id) - } - return s.Get(record.Id) -} - -func (s *Service) Refresh(ctx context.Context, localOrderID string) (*core.Record, error) { - record, err := s.Get(localOrderID) - if err != nil { - return nil, err - } - paymentID := record.GetString("razorpay_payment_id") - if paymentID == "" { - return nil, domain.New("RAZORPAY_LIVE_PAYMENT_UNKNOWN", "no Razorpay payment id is linked to this order yet", 409) - } - payment, err := s.Client.FetchPayment(ctx, paymentID) - if err != nil { - return nil, domain.New("RAZORPAY_LIVE_REFRESH_FAILED", "could not fetch the Razorpay payment", 502) - } - if err := s.applyPayment(record.Id, payment, s.now()); err != nil { - return nil, err - } - return s.Get(record.Id) -} - -func (s *Service) IngestWebhook(eventID, signature string, raw []byte) (WebhookResult, error) { - eventID = strings.TrimSpace(eventID) - if eventID == "" || len(eventID) > 128 { - return WebhookResult{}, domain.New("RAZORPAY_LIVE_EVENT_ID_REQUIRED", "X-Razorpay-Event-Id is required", 400) - } - if len(raw) == 0 || len(raw) > maxWebhookBytes { - return WebhookResult{}, domain.New("RAZORPAY_LIVE_WEBHOOK_INVALID", "webhook body must be between 1 byte and 1 MiB", 400) - } - if !verifyHexHMAC(s.WebhookSecret, string(raw), signature) { - return WebhookResult{}, domain.New("RAZORPAY_LIVE_WEBHOOK_SIGNATURE_INVALID", "invalid Razorpay webhook signature", 401) - } - hashBytes := sha256.Sum256(raw) - payloadHash := hex.EncodeToString(hashBytes[:]) - if existing, err := s.App.FindFirstRecordByData("razorpay_live_events", "event_id", eventID); err == nil { - if existing.GetString("payload_hash") != payloadHash { - return WebhookResult{}, domain.New("RAZORPAY_LIVE_EVENT_ID_CONFLICT", "the Razorpay event id was already used with a different payload", 409) - } - return WebhookResult{Duplicate: true, EventID: eventID, OrderID: existing.GetString("live_order"), Status: existing.GetString("status")}, nil - } else if !errors.Is(err, sql.ErrNoRows) { - return WebhookResult{}, err - } - - var envelope webhookEnvelope - if err := json.Unmarshal(raw, &envelope); err != nil { - return WebhookResult{}, domain.New("RAZORPAY_LIVE_WEBHOOK_INVALID", "invalid Razorpay webhook JSON", 400) - } - payment := envelope.Payload.Payment.Entity - result := WebhookResult{EventID: eventID} - now := s.now() - err := s.App.RunInTransaction(func(tx core.App) error { - if existing, err := tx.FindFirstRecordByData("razorpay_live_events", "event_id", eventID); err == nil { - if existing.GetString("payload_hash") != payloadHash { - return domain.New("RAZORPAY_LIVE_EVENT_ID_CONFLICT", "the Razorpay event id was already used with a different payload", 409) - } - result.Duplicate = true - result.OrderID = existing.GetString("live_order") - result.Status = existing.GetString("status") - return nil - } else if !errors.Is(err, sql.ErrNoRows) { - return err - } - collection, err := tx.FindCollectionByNameOrId("razorpay_live_events") - if err != nil { - return err - } - event := core.NewRecord(collection) - event.Set("event_id", eventID) - event.Set("event_type", truncate(envelope.Event, 128)) - event.Set("razorpay_order_id", payment.OrderID) - event.Set("razorpay_payment_id", payment.ID) - event.Set("payload_hash", payloadHash) - event.Set("received_at", now) - if envelope.CreatedAt > 0 { - event.Set("provider_created_at", time.Unix(envelope.CreatedAt, 0).UTC()) - } - - order, findErr := tx.FindFirstRecordByData("razorpay_live_orders", "razorpay_order_id", payment.OrderID) - if errors.Is(findErr, sql.ErrNoRows) { - event.Set("status", "ignored") - event.Set("error", "No local Razorpay live order matches this event") - result.Ignored = true - result.Status = "ignored" - return tx.Save(event) - } - if findErr != nil { - return findErr - } - event.Set("live_order", order.Id) - result.OrderID = order.Id - if envelope.Event != "payment.captured" && envelope.Event != "payment.failed" { - event.Set("status", "ignored") - result.Ignored = true - result.Status = "ignored" - return tx.Save(event) - } - if err := validateProviderPayment(order, payment); err != nil { - event.Set("status", "failed") - event.Set("error", truncate(err.Error(), 4096)) - result.Status = "failed" - return tx.Save(event) - } - if err := applyProviderPayment(order, payment, now); err != nil { - return err - } - if err := tx.Save(order); err != nil { - return err - } - event.Set("status", "processed") - result.Processed = true - result.Status = order.GetString("status") - return tx.Save(event) - }) - return result, err -} - -func (s *Service) applyPayment(localOrderID string, payment ProviderPayment, at time.Time) error { - return s.App.RunInTransaction(func(tx core.App) error { - order, err := tx.FindRecordById("razorpay_live_orders", localOrderID) - if err != nil { - return err - } - if err := validateProviderPayment(order, payment); err != nil { - return err - } - if err := applyProviderPayment(order, payment, at); err != nil { - return err - } - return tx.Save(order) - }) -} - -func validateProviderPayment(order *core.Record, payment ProviderPayment) error { - if payment.ID == "" || payment.OrderID != order.GetString("razorpay_order_id") { - return domain.New("RAZORPAY_LIVE_PROVIDER_MISMATCH", "Razorpay payment does not belong to the local order", 409) - } - if payment.Amount != int64(order.GetInt("amount")) || !strings.EqualFold(payment.Currency, order.GetString("currency")) { - return domain.New("RAZORPAY_LIVE_PROVIDER_MISMATCH", "Razorpay payment amount or currency does not match the local order", 409) - } - return nil -} - -func applyProviderPayment(order *core.Record, payment ProviderPayment, at time.Time) error { - if existing := order.GetString("razorpay_payment_id"); existing != "" && existing != payment.ID { - return domain.New("RAZORPAY_LIVE_PAYMENT_CONFLICT", "the local order is linked to another Razorpay payment", 409) - } - current := order.GetString("status") - next := localStatus(payment) - if !shouldApplyStatus(current, next) { - return nil - } - order.Set("razorpay_payment_id", payment.ID) - order.Set("payment_method", truncate(payment.Method, 64)) - order.Set("provider_status", truncate(payment.Status, 64)) - order.Set("amount_refunded", payment.AmountRefunded) - order.Set("last_synced_at", at) - order.Set("error", truncate(firstNonEmpty(payment.ErrorDescription, payment.ErrorCode), 4096)) - order.Set("status", next) - switch next { - case "captured": - order.Set("captured_at", at) - case "failed": - order.Set("failed_at", at) - } - return nil -} - -func localStatus(payment ProviderPayment) string { - if payment.AmountRefunded >= payment.Amount && payment.Amount > 0 { - return "refunded" - } - if payment.AmountRefunded > 0 { - return "partially_refunded" - } - switch payment.Status { - case "captured": - return "captured" - case "authorized": - return "authorized" - case "failed": - return "failed" - case "refunded": - return "refunded" - default: - return "verification_pending" - } + return razorpaycore.NewService(app, client, keyID, keySecret, webhookSecret, displayName, razorpaycore.LiveMode()) } -func shouldApplyStatus(current, next string) bool { - if current == next { - return true - } - if current == "refunded" { - return false - } - if current == "partially_refunded" { - return next == "refunded" - } - if current == "captured" { - return next == "partially_refunded" || next == "refunded" - } - if next == "captured" || next == "partially_refunded" || next == "refunded" { - return true - } - if current == "failed" { - return false - } - return true -} - -func verifyHexHMAC(secret, message, provided string) bool { - providedBytes, err := hex.DecodeString(strings.TrimSpace(provided)) - if err != nil { - return false - } - mac := hmac.New(sha256.New, []byte(secret)) - _, _ = mac.Write([]byte(message)) - expected := mac.Sum(nil) - return len(providedBytes) == len(expected) && subtle.ConstantTimeCompare(providedBytes, expected) == 1 -} - -func Sign(secret string, body []byte) string { - mac := hmac.New(sha256.New, []byte(secret)) - _, _ = mac.Write(body) - return hex.EncodeToString(mac.Sum(nil)) -} - -func OrderResponse(record *core.Record, keyID, displayName string) map[string]any { - if record == nil { - return nil - } - return map[string]any{ - "id": record.Id, "amountPaise": record.GetInt("amount"), "currency": record.GetString("currency"), - "status": record.GetString("status"), "externalId": record.GetString("external_id"), - "razorpayOrderId": record.GetString("razorpay_order_id"), "razorpayPaymentId": record.GetString("razorpay_payment_id"), - "providerStatus": record.GetString("provider_status"), "paymentMethod": record.GetString("payment_method"), - "amountRefunded": record.GetInt("amount_refunded"), "error": record.GetString("error"), - "createdAt": record.GetDateTime("created_at").String(), "capturedAt": record.GetDateTime("captured_at").String(), - "keyId": keyID, "displayName": displayName, - } -} - -func (s *Service) now() time.Time { - if s.Now == nil { - return time.Now().UTC() - } - return s.Now().UTC() -} - -func truncate(value string, max int) string { - if len(value) <= max { - return value - } - return value[:max] -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} +var Sign = razorpaycore.Sign +var OrderResponse = razorpaycore.OrderResponse diff --git a/internal/razorpaytest/client.go b/internal/razorpaytest/client.go index 8911d46..ec71bb1 100644 --- a/internal/razorpaytest/client.go +++ b/internal/razorpaytest/client.go @@ -1,161 +1,11 @@ package razorpaytest -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" -) +import "github.com/Phloraxx/payment-api/internal/razorpaycore" -const productionAPIBaseURL = "https://api.razorpay.com/v1" -const maxProviderResponseBytes = 1 << 20 - -type Client struct { - KeyID string - KeySecret string - HTTP *http.Client - baseURL string -} - -type ProviderOrder struct { - ID string `json:"id"` - Entity string `json:"entity"` - Amount int64 `json:"amount"` - Currency string `json:"currency"` - Receipt string `json:"receipt"` - Status string `json:"status"` -} - -type ProviderPayment struct { - ID string `json:"id"` - Entity string `json:"entity"` - Amount int64 `json:"amount"` - Currency string `json:"currency"` - Status string `json:"status"` - OrderID string `json:"order_id"` - Method string `json:"method"` - AmountRefunded int64 `json:"amount_refunded"` - Captured bool `json:"captured"` - ErrorCode string `json:"error_code"` - ErrorDescription string `json:"error_description"` -} +type Client = razorpaycore.Client +type ProviderOrder = razorpaycore.ProviderOrder +type ProviderPayment = razorpaycore.ProviderPayment func NewClient(keyID, keySecret string) *Client { - return &Client{ - KeyID: keyID, KeySecret: keySecret, - HTTP: &http.Client{ - Timeout: 12 * time.Second, - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - }, - }, - baseURL: productionAPIBaseURL, - } -} - -func (c *Client) CreateOrder(ctx context.Context, amountPaise int64, receipt string) (ProviderOrder, error) { - payload := map[string]any{ - "amount": amountPaise, "currency": "INR", "receipt": receipt, - "notes": map[string]string{"source": "paygate_razorpay_test"}, - } - var order ProviderOrder - if err := c.doJSON(ctx, http.MethodPost, "/orders", payload, &order); err != nil { - return ProviderOrder{}, err - } - if !strings.HasPrefix(order.ID, "order_") || order.Amount != amountPaise || !strings.EqualFold(order.Currency, "INR") { - return ProviderOrder{}, errors.New("razorpay returned an inconsistent order") - } - return order, nil -} - -func (c *Client) FetchPayment(ctx context.Context, paymentID string) (ProviderPayment, error) { - if !strings.HasPrefix(paymentID, "pay_") { - return ProviderPayment{}, errors.New("invalid razorpay payment id") - } - var payment ProviderPayment - if err := c.doJSON(ctx, http.MethodGet, "/payments/"+paymentID, nil, &payment); err != nil { - return ProviderPayment{}, err - } - if payment.ID != paymentID { - return ProviderPayment{}, errors.New("razorpay returned an inconsistent payment id") - } - return payment, nil -} - -func (c *Client) doJSON(ctx context.Context, method, path string, requestBody any, responseBody any) error { - var body io.Reader - if requestBody != nil { - raw, err := json.Marshal(requestBody) - if err != nil { - return err - } - body = bytes.NewReader(raw) - } - req, err := http.NewRequestWithContext(ctx, method, c.apiBaseURL()+path, body) - if err != nil { - return err - } - req.SetBasicAuth(c.KeyID, c.KeySecret) - req.Header.Set("Accept", "application/json") - if requestBody != nil { - req.Header.Set("Content-Type", "application/json") - } - res, err := c.httpClient().Do(req) - if err != nil { - return fmt.Errorf("razorpay request failed: %w", err) - } - defer res.Body.Close() - raw, err := io.ReadAll(io.LimitReader(res.Body, maxProviderResponseBytes+1)) - if err != nil { - return fmt.Errorf("read razorpay response: %w", err) - } - if len(raw) > maxProviderResponseBytes { - return errors.New("razorpay response exceeded 1 MiB") - } - if res.StatusCode < 200 || res.StatusCode >= 300 { - return fmt.Errorf("razorpay returned HTTP %d: %s", res.StatusCode, providerErrorMessage(raw)) - } - if err := json.Unmarshal(raw, responseBody); err != nil { - return fmt.Errorf("decode razorpay response: %w", err) - } - return nil -} - -func (c *Client) apiBaseURL() string { - if c.baseURL != "" { - return strings.TrimRight(c.baseURL, "/") - } - return productionAPIBaseURL -} - -func (c *Client) httpClient() *http.Client { - if c.HTTP != nil { - return c.HTTP - } - return NewClient(c.KeyID, c.KeySecret).HTTP -} - -func providerErrorMessage(raw []byte) string { - var envelope struct { - Error struct { - Code string `json:"code"` - Description string `json:"description"` - } `json:"error"` - } - if json.Unmarshal(raw, &envelope) == nil && envelope.Error.Description != "" { - return envelope.Error.Description - } - text := strings.TrimSpace(string(raw)) - if len(text) > 512 { - text = text[:512] - } - if text == "" { - return "empty error response" - } - return text + return razorpaycore.NewClient(keyID, keySecret, "paygate_razorpay_test") } diff --git a/internal/razorpaytest/client_test.go b/internal/razorpaytest/client_test.go index 6fd7ec4..3b48c5b 100644 --- a/internal/razorpaytest/client_test.go +++ b/internal/razorpaytest/client_test.go @@ -26,7 +26,7 @@ func TestClientCreatesOrderWithBasicAuthAndRefusesRedirects(t *testing.T) { })) defer server.Close() client := NewClient("rzp_test_key", "secret") - client.baseURL = server.URL + client.BaseURL = server.URL _, err := client.CreateOrder(context.Background(), 100, "receipt") if err == nil { t.Fatal("expected redirect response to be rejected") @@ -43,7 +43,7 @@ func TestClientValidatesProviderOrderAmount(t *testing.T) { })) defer server.Close() client := NewClient("rzp_test_key", "secret") - client.baseURL = server.URL + client.BaseURL = server.URL if _, err := client.CreateOrder(context.Background(), 100, "receipt"); err == nil { t.Fatal("expected inconsistent amount error") } diff --git a/internal/razorpaytest/service.go b/internal/razorpaytest/service.go index 283597a..34ef3cf 100644 --- a/internal/razorpaytest/service.go +++ b/internal/razorpaytest/service.go @@ -1,466 +1,19 @@ package razorpaytest import ( - "context" - "crypto/hmac" - "crypto/sha256" - "crypto/subtle" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "strings" - "time" - - "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/razorpaycore" "github.com/pocketbase/pocketbase/core" ) -const maxWebhookBytes = 1 << 20 - -type ProviderClient interface { - CreateOrder(ctx context.Context, amountPaise int64, receipt string) (ProviderOrder, error) - FetchPayment(ctx context.Context, paymentID string) (ProviderPayment, error) -} - -type Service struct { - App core.App - Client ProviderClient - KeyID string - KeySecret string - WebhookSecret string - DisplayName string - Now func() time.Time -} - -type CreateInput struct { - AmountPaise int64 - ExternalID string - IdempotencyKey string - ActorID string -} - -type VerifyInput struct { - LocalOrderID string - RazorpayOrderID string - RazorpayPaymentID string - RazorpaySignature string -} - -type WebhookResult struct { - Duplicate bool `json:"duplicate"` - Processed bool `json:"processed"` - Ignored bool `json:"ignored"` - EventID string `json:"eventId"` - OrderID string `json:"orderId,omitempty"` - Status string `json:"status,omitempty"` -} - -type webhookEnvelope struct { - Event string `json:"event"` - CreatedAt int64 `json:"created_at"` - Payload struct { - Payment struct { - Entity ProviderPayment `json:"entity"` - } `json:"payment"` - } `json:"payload"` -} +type ProviderClient = razorpaycore.ProviderClient +type Service = razorpaycore.Service +type CreateInput = razorpaycore.CreateInput +type VerifyInput = razorpaycore.VerifyInput +type WebhookResult = razorpaycore.WebhookResult func NewService(app core.App, client ProviderClient, keyID, keySecret, webhookSecret, displayName string) *Service { - return &Service{ - App: app, Client: client, KeyID: keyID, KeySecret: keySecret, - WebhookSecret: webhookSecret, DisplayName: displayName, Now: time.Now, - } -} - -func (s *Service) Create(ctx context.Context, input CreateInput) (*core.Record, bool, error) { - input.ExternalID = strings.TrimSpace(input.ExternalID) - input.IdempotencyKey = strings.TrimSpace(input.IdempotencyKey) - if input.AmountPaise < 100 || input.AmountPaise > 100_000_00 { - return nil, false, domain.New("RAZORPAY_TEST_INVALID_AMOUNT", "test amount must be between ₹1 and ₹1,00,000", 400) - } - if input.IdempotencyKey == "" || len(input.IdempotencyKey) > 255 { - return nil, false, domain.New("RAZORPAY_TEST_IDEMPOTENCY_REQUIRED", "a valid Idempotency-Key is required", 400) - } - if len(input.ExternalID) > 255 { - return nil, false, domain.InvalidExternalID() - } - - if existing, err := s.App.FindFirstRecordByData("razorpay_test_orders", "idempotency_key", input.IdempotencyKey); err == nil { - if int64(existing.GetInt("amount")) != input.AmountPaise || existing.GetString("external_id") != input.ExternalID { - return nil, false, domain.New("RAZORPAY_TEST_IDEMPOTENCY_CONFLICT", "the idempotency key was already used with different parameters", 409) - } - if status := existing.GetString("status"); status == "creating" || status == "create_failed" { - domainErr := domain.New("RAZORPAY_TEST_CREATE_STATE_UNKNOWN", "the previous provider-order attempt did not complete cleanly; inspect the Razorpay Test Dashboard using the local receipt before starting a new attempt", 409) - domainErr.Details = map[string]any{"localOrderId": existing.Id, "receipt": "pgt_" + existing.Id, "status": status} - return nil, false, domainErr - } - return existing, true, nil - } else if !errors.Is(err, sql.ErrNoRows) { - return nil, false, err - } - - collection, err := s.App.FindCollectionByNameOrId("razorpay_test_orders") - if err != nil { - return nil, false, err - } - now := s.now() - record := core.NewRecord(collection) - record.Set("amount", input.AmountPaise) - record.Set("currency", "INR") - record.Set("status", "creating") - record.Set("external_id", input.ExternalID) - record.Set("idempotency_key", input.IdempotencyKey) - record.Set("created_by", input.ActorID) - record.Set("created_at", now) - if err := s.App.Save(record); err != nil { - if existing, findErr := s.App.FindFirstRecordByData("razorpay_test_orders", "idempotency_key", input.IdempotencyKey); findErr == nil { - return existing, true, nil - } - return nil, false, err - } - - providerOrder, err := s.Client.CreateOrder(ctx, input.AmountPaise, "pgt_"+record.Id) - if err != nil { - record.Set("status", "create_failed") - record.Set("error", truncate(err.Error(), 4096)) - record.Set("last_synced_at", now) - _ = s.App.Save(record) - domainErr := domain.New("RAZORPAY_TEST_CREATE_FAILED", "Razorpay test order creation failed", 502) - domainErr.Details = map[string]any{"localOrderId": record.Id} - return record, false, domainErr - } - record.Set("razorpay_order_id", providerOrder.ID) - record.Set("provider_status", providerOrder.Status) - record.Set("status", "created") - record.Set("error", "") - record.Set("last_synced_at", now) - if err := s.App.Save(record); err != nil { - return nil, false, err - } - return record, false, nil -} - -func (s *Service) Get(localOrderID string) (*core.Record, error) { - record, err := s.App.FindRecordById("razorpay_test_orders", strings.TrimSpace(localOrderID)) - if errors.Is(err, sql.ErrNoRows) { - return nil, domain.New("RAZORPAY_TEST_ORDER_NOT_FOUND", "Razorpay test order not found", 404) - } - return record, err -} - -func (s *Service) Verify(ctx context.Context, input VerifyInput) (*core.Record, error) { - record, err := s.Get(input.LocalOrderID) - if err != nil { - return nil, err - } - providerOrderID := record.GetString("razorpay_order_id") - if providerOrderID == "" || input.RazorpayOrderID != providerOrderID { - return nil, domain.New("RAZORPAY_TEST_ORDER_MISMATCH", "checkout order id does not match the server-created order", 400) - } - if !strings.HasPrefix(input.RazorpayPaymentID, "pay_") { - return nil, domain.New("RAZORPAY_TEST_INVALID_PAYMENT", "invalid Razorpay payment id", 400) - } - if !verifyHexHMAC(s.KeySecret, providerOrderID+"|"+input.RazorpayPaymentID, input.RazorpaySignature) { - return nil, domain.New("RAZORPAY_TEST_SIGNATURE_INVALID", "Razorpay checkout signature verification failed", 400) - } - - err = s.App.RunInTransaction(func(tx core.App) error { - current, err := tx.FindRecordById("razorpay_test_orders", record.Id) - if err != nil { - return err - } - if existing := current.GetString("razorpay_payment_id"); existing != "" && existing != input.RazorpayPaymentID { - return domain.New("RAZORPAY_TEST_PAYMENT_CONFLICT", "the order is already linked to another Razorpay payment", 409) - } - if other, findErr := tx.FindFirstRecordByData("razorpay_test_orders", "razorpay_payment_id", input.RazorpayPaymentID); findErr == nil && other.Id != current.Id { - return domain.New("RAZORPAY_TEST_PAYMENT_CONFLICT", "the Razorpay payment is already linked to another test order", 409) - } else if findErr != nil && !errors.Is(findErr, sql.ErrNoRows) { - return findErr - } - current.Set("razorpay_payment_id", input.RazorpayPaymentID) - current.Set("signature_verified_at", s.now()) - if current.GetString("status") != "captured" && current.GetString("status") != "refunded" && current.GetString("status") != "partially_refunded" { - current.Set("status", "verification_pending") - } - return tx.Save(current) - }) - if err != nil { - return nil, err - } - - // A signed browser callback proves authenticity, not capture. Fetch the - // provider state immediately for responsive test UX; webhooks remain the - // authoritative asynchronous path if this fetch fails. - if _, refreshErr := s.Refresh(ctx, record.Id); refreshErr != nil { - var domainErr *domain.Error - if errors.As(refreshErr, &domainErr) && domainErr.Code != "RAZORPAY_TEST_REFRESH_FAILED" { - return nil, refreshErr - } - return s.Get(record.Id) - } - return s.Get(record.Id) -} - -func (s *Service) Refresh(ctx context.Context, localOrderID string) (*core.Record, error) { - record, err := s.Get(localOrderID) - if err != nil { - return nil, err - } - paymentID := record.GetString("razorpay_payment_id") - if paymentID == "" { - return nil, domain.New("RAZORPAY_TEST_PAYMENT_UNKNOWN", "no Razorpay payment id is linked to this order yet", 409) - } - payment, err := s.Client.FetchPayment(ctx, paymentID) - if err != nil { - return nil, domain.New("RAZORPAY_TEST_REFRESH_FAILED", "could not fetch the Razorpay payment", 502) - } - if err := s.applyPayment(record.Id, payment, s.now()); err != nil { - return nil, err - } - return s.Get(record.Id) + return razorpaycore.NewService(app, client, keyID, keySecret, webhookSecret, displayName, razorpaycore.TestMode()) } -func (s *Service) IngestWebhook(eventID, signature string, raw []byte) (WebhookResult, error) { - eventID = strings.TrimSpace(eventID) - if eventID == "" || len(eventID) > 128 { - return WebhookResult{}, domain.New("RAZORPAY_TEST_EVENT_ID_REQUIRED", "X-Razorpay-Event-Id is required", 400) - } - if len(raw) == 0 || len(raw) > maxWebhookBytes { - return WebhookResult{}, domain.New("RAZORPAY_TEST_WEBHOOK_INVALID", "webhook body must be between 1 byte and 1 MiB", 400) - } - if !verifyHexHMAC(s.WebhookSecret, string(raw), signature) { - return WebhookResult{}, domain.New("RAZORPAY_TEST_WEBHOOK_SIGNATURE_INVALID", "invalid Razorpay webhook signature", 401) - } - hashBytes := sha256.Sum256(raw) - payloadHash := hex.EncodeToString(hashBytes[:]) - if existing, err := s.App.FindFirstRecordByData("razorpay_test_events", "event_id", eventID); err == nil { - if existing.GetString("payload_hash") != payloadHash { - return WebhookResult{}, domain.New("RAZORPAY_TEST_EVENT_ID_CONFLICT", "the Razorpay event id was already used with a different payload", 409) - } - return WebhookResult{Duplicate: true, EventID: eventID, OrderID: existing.GetString("test_order"), Status: existing.GetString("status")}, nil - } else if !errors.Is(err, sql.ErrNoRows) { - return WebhookResult{}, err - } - - var envelope webhookEnvelope - if err := json.Unmarshal(raw, &envelope); err != nil { - return WebhookResult{}, domain.New("RAZORPAY_TEST_WEBHOOK_INVALID", "invalid Razorpay webhook JSON", 400) - } - payment := envelope.Payload.Payment.Entity - result := WebhookResult{EventID: eventID} - now := s.now() - err := s.App.RunInTransaction(func(tx core.App) error { - if existing, err := tx.FindFirstRecordByData("razorpay_test_events", "event_id", eventID); err == nil { - if existing.GetString("payload_hash") != payloadHash { - return domain.New("RAZORPAY_TEST_EVENT_ID_CONFLICT", "the Razorpay event id was already used with a different payload", 409) - } - result.Duplicate = true - result.OrderID = existing.GetString("test_order") - result.Status = existing.GetString("status") - return nil - } else if !errors.Is(err, sql.ErrNoRows) { - return err - } - collection, err := tx.FindCollectionByNameOrId("razorpay_test_events") - if err != nil { - return err - } - event := core.NewRecord(collection) - event.Set("event_id", eventID) - event.Set("event_type", truncate(envelope.Event, 128)) - event.Set("razorpay_order_id", payment.OrderID) - event.Set("razorpay_payment_id", payment.ID) - event.Set("payload_hash", payloadHash) - event.Set("received_at", now) - if envelope.CreatedAt > 0 { - event.Set("provider_created_at", time.Unix(envelope.CreatedAt, 0).UTC()) - } - - order, findErr := tx.FindFirstRecordByData("razorpay_test_orders", "razorpay_order_id", payment.OrderID) - if errors.Is(findErr, sql.ErrNoRows) { - event.Set("status", "ignored") - event.Set("error", "No local Razorpay test order matches this event") - result.Ignored = true - result.Status = "ignored" - return tx.Save(event) - } - if findErr != nil { - return findErr - } - event.Set("test_order", order.Id) - result.OrderID = order.Id - if envelope.Event != "payment.captured" && envelope.Event != "payment.failed" { - event.Set("status", "ignored") - result.Ignored = true - result.Status = "ignored" - return tx.Save(event) - } - if err := validateProviderPayment(order, payment); err != nil { - event.Set("status", "failed") - event.Set("error", truncate(err.Error(), 4096)) - result.Status = "failed" - return tx.Save(event) - } - if err := applyProviderPayment(order, payment, now); err != nil { - return err - } - if err := tx.Save(order); err != nil { - return err - } - event.Set("status", "processed") - result.Processed = true - result.Status = order.GetString("status") - return tx.Save(event) - }) - return result, err -} - -func (s *Service) applyPayment(localOrderID string, payment ProviderPayment, at time.Time) error { - return s.App.RunInTransaction(func(tx core.App) error { - order, err := tx.FindRecordById("razorpay_test_orders", localOrderID) - if err != nil { - return err - } - if err := validateProviderPayment(order, payment); err != nil { - return err - } - if err := applyProviderPayment(order, payment, at); err != nil { - return err - } - return tx.Save(order) - }) -} - -func validateProviderPayment(order *core.Record, payment ProviderPayment) error { - if payment.ID == "" || payment.OrderID != order.GetString("razorpay_order_id") { - return domain.New("RAZORPAY_TEST_PROVIDER_MISMATCH", "Razorpay payment does not belong to the local order", 409) - } - if payment.Amount != int64(order.GetInt("amount")) || !strings.EqualFold(payment.Currency, order.GetString("currency")) { - return domain.New("RAZORPAY_TEST_PROVIDER_MISMATCH", "Razorpay payment amount or currency does not match the local order", 409) - } - return nil -} - -func applyProviderPayment(order *core.Record, payment ProviderPayment, at time.Time) error { - if existing := order.GetString("razorpay_payment_id"); existing != "" && existing != payment.ID { - return domain.New("RAZORPAY_TEST_PAYMENT_CONFLICT", "the local order is linked to another Razorpay payment", 409) - } - current := order.GetString("status") - next := localStatus(payment) - if !shouldApplyStatus(current, next) { - return nil - } - order.Set("razorpay_payment_id", payment.ID) - order.Set("payment_method", truncate(payment.Method, 64)) - order.Set("provider_status", truncate(payment.Status, 64)) - order.Set("amount_refunded", payment.AmountRefunded) - order.Set("last_synced_at", at) - order.Set("error", truncate(firstNonEmpty(payment.ErrorDescription, payment.ErrorCode), 4096)) - order.Set("status", next) - switch next { - case "captured": - order.Set("captured_at", at) - case "failed": - order.Set("failed_at", at) - } - return nil -} - -func localStatus(payment ProviderPayment) string { - if payment.AmountRefunded >= payment.Amount && payment.Amount > 0 { - return "refunded" - } - if payment.AmountRefunded > 0 { - return "partially_refunded" - } - switch payment.Status { - case "captured": - return "captured" - case "authorized": - return "authorized" - case "failed": - return "failed" - case "refunded": - return "refunded" - default: - return "verification_pending" - } -} - -func shouldApplyStatus(current, next string) bool { - if current == next { - return true - } - if current == "refunded" { - return false - } - if current == "partially_refunded" { - return next == "refunded" - } - if current == "captured" { - return next == "partially_refunded" || next == "refunded" - } - if next == "captured" || next == "partially_refunded" || next == "refunded" { - return true - } - if current == "failed" { - return false - } - return true -} - -func verifyHexHMAC(secret, message, provided string) bool { - providedBytes, err := hex.DecodeString(strings.TrimSpace(provided)) - if err != nil { - return false - } - mac := hmac.New(sha256.New, []byte(secret)) - _, _ = mac.Write([]byte(message)) - expected := mac.Sum(nil) - return len(providedBytes) == len(expected) && subtle.ConstantTimeCompare(providedBytes, expected) == 1 -} - -func Sign(secret string, body []byte) string { - mac := hmac.New(sha256.New, []byte(secret)) - _, _ = mac.Write(body) - return hex.EncodeToString(mac.Sum(nil)) -} - -func OrderResponse(record *core.Record, keyID, displayName string) map[string]any { - if record == nil { - return nil - } - return map[string]any{ - "id": record.Id, "amountPaise": record.GetInt("amount"), "currency": record.GetString("currency"), - "status": record.GetString("status"), "externalId": record.GetString("external_id"), - "razorpayOrderId": record.GetString("razorpay_order_id"), "razorpayPaymentId": record.GetString("razorpay_payment_id"), - "providerStatus": record.GetString("provider_status"), "paymentMethod": record.GetString("payment_method"), - "amountRefunded": record.GetInt("amount_refunded"), "error": record.GetString("error"), - "createdAt": record.GetDateTime("created_at").String(), "capturedAt": record.GetDateTime("captured_at").String(), - "keyId": keyID, "displayName": displayName, - } -} - -func (s *Service) now() time.Time { - if s.Now == nil { - return time.Now().UTC() - } - return s.Now().UTC() -} - -func truncate(value string, max int) string { - if len(value) <= max { - return value - } - return value[:max] -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} +var Sign = razorpaycore.Sign +var OrderResponse = razorpaycore.OrderResponse diff --git a/internal/reconciliation/service.go b/internal/reconciliation/service.go index 90a885e..341a5af 100644 --- a/internal/reconciliation/service.go +++ b/internal/reconciliation/service.go @@ -3,6 +3,7 @@ package reconciliation import ( "archive/zip" "bytes" + "context" "crypto/sha256" "database/sql" "encoding/csv" @@ -20,22 +21,22 @@ import ( "github.com/Phloraxx/payment-api/internal/audit" "github.com/Phloraxx/payment-api/internal/domain" "github.com/Phloraxx/payment-api/internal/money" - "github.com/Phloraxx/payment-api/internal/payments" "github.com/Phloraxx/payment-api/internal/reviews" "github.com/Phloraxx/payment-api/internal/sms" - "github.com/pocketbase/dbx" + "github.com/Phloraxx/payment-api/internal/store" "github.com/pocketbase/pocketbase/core" "github.com/xuri/excelize/v2" ) const ( - MaxFileBytes = 10 << 20 - MaxRows = 10_000 - MaxColumns = 64 + MaxFileBytes = 10 << 20 + MaxRows = 10_000 + MaxColumns = 64 + ReconciliationBatchSize = 250 ) type Service struct { - App core.App + Store store.Database Reviews *reviews.Service Alerts *alerts.Service Audit *audit.Service @@ -80,7 +81,7 @@ func NewService(app core.App, reviewService *reviews.Service, alertService *aler if err != nil { location = time.FixedZone("IST", 5*60*60+30*60) } - return &Service{App: app, Reviews: reviewService, Alerts: alertService, Audit: auditService, Now: time.Now, StatementLocation: location} + return &Service{Store: store.NewPocketBase(app), Reviews: reviewService, Alerts: alertService, Audit: auditService, Now: time.Now, StatementLocation: location} } func (s *Service) Import(input ImportInput) (Result, error) { @@ -93,11 +94,18 @@ func (s *Service) Import(input ImportInput) (Result, error) { } hashBytes := sha256.Sum256(input.Data) hash := hex.EncodeToString(hashBytes[:]) - if existing, err := s.App.FindFirstRecordByFilter("reconciliation_runs", "sha256 = {:hash} && status = 'completed'", dbx.Params{"hash": hash}); err == nil { + var existing *domain.ReconciliationRun + err := s.Store.View(context.Background(), func(uow store.UnitOfWork) error { + var findErr error + existing, findErr = uow.ReconciliationRuns().FindCompletedByHash(hash) + return findErr + }) + if err == nil { domainErr := domain.New("STATEMENT_ALREADY_IMPORTED", "this exact statement file was already imported", 409) - domainErr.Details = map[string]any{"runId": existing.Id} + domainErr.Details = map[string]any{"runId": existing.ID} return Result{}, domainErr - } else if !errors.Is(err, sql.ErrNoRows) { + } + if !errors.Is(err, sql.ErrNoRows) { return Result{}, err } @@ -112,168 +120,180 @@ func (s *Service) Import(input ImportInput) (Result, error) { return Result{}, domain.New("STATEMENT_PARSE_FAILED", parseErr.Error(), 422) } - result := Result{RunID: run.Id, Status: "completed"} + result := Result{RunID: run.ID, Status: "completed"} seenRRN := map[string]int{} - err = s.App.RunInTransaction(func(tx core.App) error { - entryCollection, err := tx.FindCollectionByNameOrId("reconciliation_entries") - if err != nil { - return err + for start := 0; start < len(rows); start += ReconciliationBatchSize { + end := start + ReconciliationBatchSize + if end > len(rows) { + end = len(rows) } + if err := s.persistRowsBatch(run.ID, rows[start:end], seenRRN, now, &result); err != nil { + s.failRun(run, err, now) + return Result{}, err + } + } + if err := s.completeRun(run.ID, input.Actor, now, result); err != nil { + s.failRun(run, err, now) + return Result{}, err + } + if result.ConflictRows > 0 && s.Alerts != nil { + _, _, _ = s.Alerts.Open(alerts.Input{ + Kind: "reconciliation_conflict", Severity: "warning", DedupeKey: "reconciliation:" + run.ID, + Message: fmt.Sprintf("Statement reconciliation found %d conflicting rows", result.ConflictRows), Details: result, + }) + } + return result, nil +} + +func (s *Service) persistRowsBatch(runID string, rows []statementRow, seenRRN map[string]int, now time.Time, result *Result) error { + if len(rows) == 0 { + return nil + } + if len(rows) > ReconciliationBatchSize { + return fmt.Errorf("reconciliation batch exceeds %d rows", ReconciliationBatchSize) + } + delta := Result{} + err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { for _, row := range rows { - result.TotalRows++ - entry := core.NewRecord(entryCollection) - entry.Set("run", run.Id) - entry.Set("row_number", row.RowNumber) - entry.Set("transaction_time", row.TransactionTime) - entry.Set("amount", row.AmountPaise) - entry.Set("rrn", row.RRN) - entry.Set("description", truncate(row.Description, 4096)) - entry.Set("raw_row", row.Raw) - - status, paymentID, note, candidateIDs, classifyErr := s.classify(tx, row, seenRRN, now) - if classifyErr != nil { - return classifyErr + delta.TotalRows++ + status, paymentID, note, candidateIDs, err := s.classify(uow, row, seenRRN, now) + if err != nil { + return err + } + entry := &domain.ReconciliationEntry{ + RunID: runID, RowNumber: row.RowNumber, TransactionTime: row.TransactionTime, + AmountPaise: row.AmountPaise, RRN: row.RRN, Description: truncate(row.Description, 4096), + Status: status, PaymentID: paymentID, Notes: note, RawRow: row.Raw, } - entry.Set("status", status) - entry.Set("payment", paymentID) - entry.Set("notes", note) - if err := tx.Save(entry); err != nil { + if err := uow.ReconciliationEntries().Create(entry); err != nil { return err } - switch status { case "matched": - result.MatchedRows++ + delta.MatchedRows++ case "unmatched": - result.UnmatchedRows++ + delta.UnmatchedRows++ case "duplicate": - result.DuplicateRows++ + delta.DuplicateRows++ case "conflict": - result.ConflictRows++ + delta.ConflictRows++ case "invalid": - result.InvalidRows++ + delta.InvalidRows++ } - needsReview := status == "conflict" || (status == "unmatched" && (row.RRN != "" || strings.Contains(strings.ToLower(row.Description), "upi"))) if needsReview && s.Reviews != nil { - caseID, err := s.Reviews.OpenInApp(tx, reviews.OpenInput{ + caseID, err := s.Reviews.Open(uow, reviews.OpenInput{ Kind: "reconciliation_conflict", Severity: severityForStatus(status), - ReconciliationEntryID: entry.Id, PaymentID: paymentID, + ReconciliationEntryID: entry.ID, PaymentID: paymentID, CandidatePaymentIDs: candidateIDs, Reason: note, OpenedAt: now, }) if err != nil { return err } if caseID != "" { - result.ReviewCases++ + delta.ReviewCases++ } } } + return nil + }) + if err != nil { + return err + } + addResult(result, delta) + return nil +} - runRecord, err := tx.FindRecordById("reconciliation_runs", run.Id) +func (s *Service) completeRun(runID string, actor audit.Actor, now time.Time, result Result) error { + return s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + run, err := uow.ReconciliationRuns().Get(runID) if err != nil { return err } - runRecord.Set("status", "completed") - runRecord.Set("total_rows", result.TotalRows) - runRecord.Set("matched_rows", result.MatchedRows) - runRecord.Set("unmatched_rows", result.UnmatchedRows) - runRecord.Set("duplicate_rows", result.DuplicateRows) - runRecord.Set("conflict_rows", result.ConflictRows) - runRecord.Set("invalid_rows", result.InvalidRows) - runRecord.Set("completed_at", now) - runRecord.Set("summary", result) - if err := tx.Save(runRecord); err != nil { + run.Status = "completed" + run.TotalRows, run.MatchedRows, run.UnmatchedRows = result.TotalRows, result.MatchedRows, result.UnmatchedRows + run.DuplicateRows, run.ConflictRows, run.InvalidRows = result.DuplicateRows, result.ConflictRows, result.InvalidRows + run.CompletedAt, run.Summary = now, result + if err := uow.ReconciliationRuns().Save(run); err != nil { return err } if s.Audit != nil { - if err := s.Audit.RecordInApp(tx, audit.Entry{ - Action: "reconciliation.import", Actor: input.Actor, - EntityType: "reconciliation_run", EntityID: run.Id, - Summary: "Imported bank statement for reconciliation", Details: result, OccurredAt: now, - }); err != nil { + if err := s.Audit.RecordUoW(uow, audit.Entry{Action: "reconciliation.import", Actor: actor, EntityType: "reconciliation_run", EntityID: runID, Summary: "Imported bank statement for reconciliation", Details: result, OccurredAt: now}); err != nil { return err } } return nil }) - if err != nil { - s.failRun(run, err, now) - return Result{}, err - } - if result.ConflictRows > 0 && s.Alerts != nil { - _, _, _ = s.Alerts.Open(alerts.Input{ - Kind: "reconciliation_conflict", Severity: "warning", DedupeKey: "reconciliation:" + run.Id, - Message: fmt.Sprintf("Statement reconciliation found %d conflicting rows", result.ConflictRows), Details: result, - }) - } - return result, nil } -func (s *Service) createRun(input ImportInput, hash string, now time.Time) (*core.Record, error) { - collection, err := s.App.FindCollectionByNameOrId("reconciliation_runs") - if err != nil { - return nil, err +func addResult(target *Result, delta Result) { + if target == nil { + return } - record := core.NewRecord(collection) - record.Set("filename", truncate(input.Filename, 255)) - record.Set("sha256", hash) - record.Set("status", "processing") - record.Set("created_by", input.Actor.ID) - record.Set("started_at", now) - if err := s.App.Save(record); err != nil { + target.TotalRows += delta.TotalRows + target.MatchedRows += delta.MatchedRows + target.UnmatchedRows += delta.UnmatchedRows + target.DuplicateRows += delta.DuplicateRows + target.ConflictRows += delta.ConflictRows + target.InvalidRows += delta.InvalidRows + target.ReviewCases += delta.ReviewCases +} + +func (s *Service) createRun(input ImportInput, hash string, now time.Time) (*domain.ReconciliationRun, error) { + run := &domain.ReconciliationRun{Filename: truncate(input.Filename, 255), SHA256: hash, Status: "processing", CreatedBy: input.Actor.ID, StartedAt: now} + if err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { return uow.ReconciliationRuns().Create(run) }); err != nil { return nil, err } - return record, nil + return run, nil } -func (s *Service) failRun(run *core.Record, cause error, now time.Time) { +func (s *Service) failRun(run *domain.ReconciliationRun, cause error, now time.Time) { if run == nil { return } - record, err := s.App.FindRecordById("reconciliation_runs", run.Id) - if err != nil { - return - } - record.Set("status", "failed") - record.Set("error", truncate(cause.Error(), 4096)) - record.Set("completed_at", now) - _ = s.App.Save(record) + _ = s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + stored, err := uow.ReconciliationRuns().Get(run.ID) + if err != nil { + return err + } + stored.Status, stored.Error, stored.CompletedAt = "failed", truncate(cause.Error(), 4096), now + return uow.ReconciliationRuns().Save(stored) + }) } -func (s *Service) classify(app core.App, row statementRow, seenRRN map[string]int, now time.Time) (status, paymentID, note string, candidateIDs []string, err error) { +func (s *Service) classify(uow store.UnitOfWork, row statementRow, seenRRN map[string]int, now time.Time) (status, paymentID, note string, candidateIDs []string, err error) { if row.InvalidReason != "" || row.AmountPaise <= 0 { return "invalid", "", row.InvalidReason, nil, nil } + paymentsRepo := uow.Payments() if row.RRN != "" { if firstRow, ok := seenRRN[row.RRN]; ok { return "duplicate", "", fmt.Sprintf("Duplicate bank reference already appeared on row %d", firstRow), nil, nil } seenRRN[row.RRN] = row.RowNumber - existing, findErr := app.FindFirstRecordByData("payments", "rrn", row.RRN) + existing, findErr := paymentsRepo.FindByEvidenceReference(domain.EvidenceReferenceRRN, row.RRN) if findErr == nil { - if existing.GetString("payment_account") != "kotak" { - return "conflict", existing.Id, "Bank reference belongs to a non-Kotak payment", []string{existing.Id}, nil + if existing.Account != domain.PaymentAccountKotak { + return "conflict", existing.ID, "Bank reference belongs to a non-Kotak payment", []string{existing.ID}, nil } - if int64(existing.GetInt("payable_amount")) == row.AmountPaise { - return "matched", existing.Id, "Exact amount and bank reference match", []string{existing.Id}, nil + if existing.PayablePaise == row.AmountPaise { + return "matched", existing.ID, "Exact amount and bank reference match", []string{existing.ID}, nil } - return "conflict", existing.Id, "Bank reference exists in PayGate with a different amount", []string{existing.Id}, nil + return "conflict", existing.ID, "Bank reference exists in PayGate with a different amount", []string{existing.ID}, nil } if !errors.Is(findErr, sql.ErrNoRows) { return "", "", "", nil, findErr } } - - candidates, findErr := reconciliationCandidates(app, row.AmountPaise, row.TransactionTime, now) + candidates, findErr := paymentsRepo.FindReconciliationCandidates(domain.PaymentAccountKotak, row.AmountPaise, row.TransactionTime, now, 10) if findErr != nil { return "", "", "", nil, findErr } for _, candidate := range candidates { - candidateIDs = append(candidateIDs, candidate.Id) + candidateIDs = append(candidateIDs, candidate.ID) } if len(candidates) == 1 { - return "conflict", candidates[0].Id, "Exact amount matches one PayGate payment, but the bank reference was not linked", candidateIDs, nil + return "conflict", candidates[0].ID, "Exact amount matches one PayGate payment, but the bank reference was not linked", candidateIDs, nil } if len(candidates) > 1 { return "conflict", "", "Multiple historical PayGate payments are plausible for this statement row", candidateIDs, nil @@ -281,27 +301,6 @@ func (s *Service) classify(app core.App, row statementRow, seenRRN map[string]in return "unmatched", "", "No PayGate payment matches this statement credit", nil, nil } -func reconciliationCandidates(app core.App, amount int64, transactionTime, now time.Time) ([]*core.Record, error) { - if amount <= 0 { - return nil, nil - } - if !transactionTime.IsZero() { - return app.FindRecordsByFilter( - "payments", - "payment_account = 'kotak' && payable_amount = {:amount} && created_at <= {:createdBefore} && reuse_after >= {:at}", - "-created_at", 10, 0, - dbx.Params{ - "amount": amount, "at": formatDate(transactionTime), - "createdBefore": formatDate(transactionTime.Add(payments.EvidenceTimestampTolerance)), - }, - ) - } - return app.FindRecordsByFilter( - "payments", "payment_account = 'kotak' && payable_amount = {:amount} && reuse_after > {:now}", - "-created_at", 10, 0, dbx.Params{"amount": amount, "now": formatDate(now)}, - ) -} - func parseStatement(filename string, data []byte, location *time.Location) ([]statementRow, error) { ext := strings.ToLower(filepath.Ext(filename)) var table [][]string @@ -591,10 +590,6 @@ func severityForStatus(status string) string { return "warning" } -func formatDate(value time.Time) string { - return value.UTC().Format("2006-01-02 15:04:05.000Z") -} - func truncate(value string, max int) string { if len(value) <= max { return value diff --git a/internal/reconciliation/service_test.go b/internal/reconciliation/service_test.go index fe8a14a..1e67418 100644 --- a/internal/reconciliation/service_test.go +++ b/internal/reconciliation/service_test.go @@ -126,6 +126,45 @@ func TestImportIgnoresDebitRowsAndDetectsDuplicateReference(t *testing.T) { } } +func TestImportPersistsLargeStatementInBatchesAndTracksDuplicateAcrossBatches(t *testing.T) { + service, _, app, _, actor := reconciliationTestService(t) + var csv strings.Builder + csv.WriteString("Date,Credit,Narration,RRN\n") + for i := 1; i <= ReconciliationBatchSize*2+1; i++ { + rrn := fmt.Sprintf("%012d", i) + if i == ReconciliationBatchSize+1 { + rrn = "000000000001" // duplicate row 1, but in the next transaction batch + } + fmt.Fprintf(&csv, "01/08/2026,10.01,UPI credit,%s\n", rrn) + } + result, err := service.Import(ImportInput{Filename: "large.csv", Data: []byte(csv.String()), Actor: actor}) + if err != nil { + t.Fatal(err) + } + wantRows := ReconciliationBatchSize*2 + 1 + if result.TotalRows != wantRows || result.DuplicateRows != 1 || result.UnmatchedRows != wantRows-1 { + t.Fatalf("result=%+v", result) + } + entries, err := app.FindAllRecords("reconciliation_entries") + if err != nil || len(entries) != wantRows { + t.Fatalf("entries=%d err=%v", len(entries), err) + } + runs, err := app.FindAllRecords("reconciliation_runs") + if err != nil || len(runs) != 1 || runs[0].GetString("status") != "completed" || runs[0].GetInt("total_rows") != wantRows { + t.Fatalf("runs=%d status=%v total=%v err=%v", len(runs), func() string { + if len(runs) == 0 { + return "" + } + return runs[0].GetString("status") + }(), func() int { + if len(runs) == 0 { + return 0 + } + return runs[0].GetInt("total_rows") + }(), err) + } +} + func TestImportRejectsSameFileHashAfterCompletedRun(t *testing.T) { service, _, _, _, actor := reconciliationTestService(t) data := []byte("Date,Credit,Narration,RRN\n01/08/2026,10.01,UPI credit,123456789012\n") diff --git a/internal/refunds/service.go b/internal/refunds/service.go index b9485cc..93877f5 100644 --- a/internal/refunds/service.go +++ b/internal/refunds/service.go @@ -1,6 +1,7 @@ package refunds import ( + "context" "database/sql" "encoding/json" "errors" @@ -11,17 +12,17 @@ import ( "github.com/Phloraxx/payment-api/internal/audit" "github.com/Phloraxx/payment-api/internal/domain" - "github.com/pocketbase/dbx" + "github.com/Phloraxx/payment-api/internal/store" "github.com/pocketbase/pocketbase/core" ) type WebhookScheduler interface { - ScheduleRefund(app core.App, event string, payment, refund *core.Record, at time.Time) error + ScheduleRefundPayment(uow store.UnitOfWork, event string, payment *domain.Payment, refund *domain.Refund, at time.Time) error Wake() } type Service struct { - App core.App + Store store.Database Audit *audit.Service Webhooks WebhookScheduler Now func() time.Time @@ -46,10 +47,10 @@ type UpdateInput struct { } func NewService(app core.App, auditService *audit.Service, webhooks WebhookScheduler) *Service { - return &Service{App: app, Audit: auditService, Webhooks: webhooks, Now: time.Now} + return &Service{Store: store.NewPocketBase(app), Audit: auditService, Webhooks: webhooks, Now: time.Now} } -func (s *Service) Request(input RequestInput) (*core.Record, bool, error) { +func (s *Service) Request(input RequestInput) (*domain.Refund, bool, error) { input.PaymentID = strings.TrimSpace(input.PaymentID) input.Reason = strings.TrimSpace(input.Reason) input.ExternalID = strings.TrimSpace(input.ExternalID) @@ -65,79 +66,60 @@ func (s *Service) Request(input RequestInput) (*core.Record, bool, error) { return nil, false, domain.New("INVALID_REFUND_METADATA", "refund metadata must be valid JSON no larger than 1 MiB", 400) } now := s.now() - var result *core.Record - var replayed bool - var queued bool - err = s.App.RunInTransaction(func(tx core.App) error { + var result *domain.Refund + var replayed, queued bool + err = s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + repo := uow.Refunds() if input.IdempotencyKey != "" { - existing, err := tx.FindFirstRecordByData("refunds", "idempotency_key", input.IdempotencyKey) - if err == nil { - existingMetadata, metadataErr := normalizeMetadata(existing.Get("metadata")) + existing, findErr := repo.FindByIdempotencyKey(input.IdempotencyKey) + if findErr == nil { + existingMetadata, metadataErr := normalizeMetadata(existing.Metadata) if metadataErr != nil { return metadataErr } - if existing.GetString("payment") != input.PaymentID || int64(existing.GetInt("amount")) != input.AmountPaise || existing.GetString("reason") != input.Reason || existing.GetString("external_id") != input.ExternalID || !reflect.DeepEqual(existingMetadata, metadata) { + if existing.PaymentID != input.PaymentID || existing.AmountPaise != input.AmountPaise || existing.Reason != input.Reason || existing.ExternalID != input.ExternalID || !reflect.DeepEqual(existingMetadata, metadata) { return domain.New("REFUND_IDEMPOTENCY_CONFLICT", "the refund idempotency key was already used with different parameters", 409) } - result = existing.Clone() - replayed = true + result, replayed = existing, true return nil } - if !errors.Is(err, sql.ErrNoRows) { - return err + if !errors.Is(findErr, sql.ErrNoRows) { + return findErr } } - payment, err := tx.FindRecordById("payments", input.PaymentID) + payment, err := uow.Payments().Get(input.PaymentID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return domain.PaymentNotFound() } return err } - status := payment.GetString("status") - if status != "paid" && status != "late" { + if payment.Status != domain.StatusPaid && payment.Status != domain.StatusLate { return domain.New("PAYMENT_NOT_REFUNDABLE", "only paid or late payments can have refund records", 409) } - paidAmount := int64(payment.GetInt("payable_amount")) - reserved, err := reservedRefundAmount(tx, payment.Id) + reserved, err := repo.ReservedAmount(payment.ID) if err != nil { return err } - if input.AmountPaise > paidAmount-reserved { + if input.AmountPaise > payment.PayablePaise-reserved { return domain.New("REFUND_AMOUNT_EXCEEDS_AVAILABLE", "refund amount exceeds the remaining refundable payment amount", 409) } - collection, err := tx.FindCollectionByNameOrId("refunds") - if err != nil { - return err - } - record := core.NewRecord(collection) - record.Set("payment", payment.Id) - record.Set("amount", input.AmountPaise) - record.Set("status", "requested") - record.Set("reason", input.Reason) - record.Set("external_id", input.ExternalID) - record.Set("idempotency_key", input.IdempotencyKey) - record.Set("metadata", metadata) - record.Set("requested_by", input.Actor.ID) - record.Set("requested_at", now) - if err := tx.Save(record); err != nil { + refund := &domain.Refund{PaymentID: payment.ID, AmountPaise: input.AmountPaise, Status: "requested", Reason: input.Reason, ExternalID: input.ExternalID, IdempotencyKey: input.IdempotencyKey, Metadata: metadata, RequestedBy: input.Actor.ID, RequestedAt: now} + if err := repo.Create(refund); err != nil { return err } if s.Audit != nil { - if err := s.Audit.RecordInApp(tx, audit.Entry{ - Action: "refund.requested", Actor: input.Actor, EntityType: "refund", EntityID: record.Id, - Summary: "Operator recorded a refund request", Details: map[string]any{"paymentId": payment.Id, "amountPaise": input.AmountPaise, "reason": input.Reason}, OccurredAt: now, - }); err != nil { + if err := s.Audit.RecordUoW(uow, audit.Entry{Action: "refund.requested", Actor: input.Actor, EntityType: "refund", EntityID: refund.ID, Summary: "Operator recorded a refund request", Details: map[string]any{"paymentId": payment.ID, "amountPaise": input.AmountPaise, "reason": input.Reason}, OccurredAt: now}); err != nil { return err } } if s.Webhooks != nil { - if err := s.Webhooks.ScheduleRefund(tx, "refund.requested", payment, record, now); err != nil { + if err := s.Webhooks.ScheduleRefundPayment(uow, "refund.requested", payment, refund, now); err != nil { return err } queued = true } - result = record.Clone() + result = refund return nil }) if err != nil { @@ -149,7 +131,7 @@ func (s *Service) Request(input RequestInput) (*core.Record, bool, error) { return result, replayed, nil } -func (s *Service) Update(input UpdateInput) (*core.Record, error) { +func (s *Service) Update(input UpdateInput) (*domain.Refund, error) { input.RefundID = strings.TrimSpace(input.RefundID) input.Status = strings.TrimSpace(input.Status) input.Reference = strings.TrimSpace(input.Reference) @@ -161,81 +143,72 @@ func (s *Service) Update(input UpdateInput) (*core.Record, error) { return nil, domain.New("REFUND_REFERENCE_REQUIRED", "a bank refund reference is required before marking a refund completed", 400) } now := s.now() - var result *core.Record + var result *domain.Refund var queued bool - err := s.App.RunInTransaction(func(tx core.App) error { - refund, err := tx.FindRecordById("refunds", input.RefundID) + err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + repo := uow.Refunds() + refund, err := repo.Get(input.RefundID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return domain.New("REFUND_NOT_FOUND", "refund not found", 404) } return err } - current := refund.GetString("status") + current := refund.Status if current == input.Status { - if input.Reference != "" && input.Reference != refund.GetString("reference") { + if input.Reference != "" && input.Reference != refund.Reference { return domain.New("REFUND_REFERENCE_CONFLICT", "same-status retry supplied a different refund reference", 409) } - result = refund.Clone() + result = refund return nil } if !validTransition(current, input.Status) { return domain.New("INVALID_REFUND_TRANSITION", fmt.Sprintf("refund cannot transition from %s to %s", current, input.Status), 409) } + payment, err := uow.Payments().Get(refund.PaymentID) + if err != nil { + return err + } if !statusReservesFunds(current) && statusReservesFunds(input.Status) { - paymentID := refund.GetString("payment") - payment, err := tx.FindRecordById("payments", paymentID) - if err != nil { - return err - } - reserved, err := reservedRefundAmount(tx, paymentID) + reserved, err := repo.ReservedAmount(refund.PaymentID) if err != nil { return err } - available := int64(payment.GetInt("payable_amount")) - reserved - if int64(refund.GetInt("amount")) > available { + if refund.AmountPaise > payment.PayablePaise-reserved { return domain.New("REFUND_AMOUNT_EXCEEDS_AVAILABLE", "refund amount exceeds the remaining refundable payment amount", 409) } } if input.Reference != "" { - existing, findErr := tx.FindFirstRecordByData("refunds", "reference", input.Reference) - if findErr == nil && existing.Id != refund.Id { + existing, findErr := repo.FindByReference(input.Reference) + if findErr == nil && existing.ID != refund.ID { return domain.New("REFUND_REFERENCE_CONFLICT", "refund reference is already assigned", 409) } if findErr != nil && !errors.Is(findErr, sql.ErrNoRows) { return findErr } } - refund.Set("status", input.Status) + refund.Status = input.Status if input.Reference != "" { - refund.Set("reference", input.Reference) + refund.Reference = input.Reference } if input.Status == "completed" { - refund.Set("completed_at", now) + refund.CompletedAt = now } - if err := tx.Save(refund); err != nil { - return err - } - payment, err := tx.FindRecordById("payments", refund.GetString("payment")) - if err != nil { + if err := repo.Save(refund); err != nil { return err } if s.Audit != nil { - if err := s.Audit.RecordInApp(tx, audit.Entry{ - Action: "refund." + input.Status, Actor: input.Actor, EntityType: "refund", EntityID: refund.Id, - Summary: "Operator updated a refund lifecycle record", Details: map[string]any{"from": current, "to": input.Status, "reference": input.Reference, "note": input.Note}, OccurredAt: now, - }); err != nil { + if err := s.Audit.RecordUoW(uow, audit.Entry{Action: "refund." + input.Status, Actor: input.Actor, EntityType: "refund", EntityID: refund.ID, Summary: "Operator updated a refund lifecycle record", Details: map[string]any{"from": current, "to": input.Status, "reference": input.Reference, "note": input.Note}, OccurredAt: now}); err != nil { return err } } if s.Webhooks != nil { - event := "refund." + input.Status - if err := s.Webhooks.ScheduleRefund(tx, event, payment, refund, now); err != nil { + if err := s.Webhooks.ScheduleRefundPayment(uow, "refund."+input.Status, payment, refund, now); err != nil { return err } queued = true } - result = refund.Clone() + result = refund return nil }) if err != nil { @@ -247,18 +220,6 @@ func (s *Service) Update(input UpdateInput) (*core.Record, error) { return result, nil } -func reservedRefundAmount(app core.App, paymentID string) (int64, error) { - records, err := app.FindRecordsByFilter("refunds", "payment = {:payment} && status != 'cancelled' && status != 'failed'", "created", 0, 0, dbx.Params{"payment": paymentID}) - if err != nil { - return 0, err - } - var total int64 - for _, record := range records { - total += int64(record.GetInt("amount")) - } - return total, nil -} - func statusReservesFunds(status string) bool { switch status { case "requested", "processing", "completed": diff --git a/internal/refunds/service_test.go b/internal/refunds/service_test.go index 412bcc2..71d9ab9 100644 --- a/internal/refunds/service_test.go +++ b/internal/refunds/service_test.go @@ -9,6 +9,7 @@ import ( "github.com/Phloraxx/payment-api/internal/config" "github.com/Phloraxx/payment-api/internal/domain" "github.com/Phloraxx/payment-api/internal/payments" + "github.com/Phloraxx/payment-api/internal/store" "github.com/Phloraxx/payment-api/internal/webhooks" _ "github.com/Phloraxx/payment-api/migrations" "github.com/pocketbase/pocketbase/core" @@ -20,7 +21,7 @@ type fakeWebhookScheduler struct { wakes int } -func (f *fakeWebhookScheduler) ScheduleRefund(_ core.App, event string, _, _ *core.Record, _ time.Time) error { +func (f *fakeWebhookScheduler) ScheduleRefundPayment(_ store.UnitOfWork, event string, _ *domain.Payment, _ *domain.Refund, _ time.Time) error { f.events = append(f.events, event) return nil } @@ -84,7 +85,7 @@ func TestRefundRequestIsAuditedIdempotentAndBounded(t *testing.T) { PaymentID: payment.ID, AmountPaise: 5000, Reason: "Customer requested partial refund", ExternalID: "refund-order-1", IdempotencyKey: "refund-idem-1", Actor: actor, }) - if err != nil || !replayed || second.Id != first.Id { + if err != nil || !replayed || second.ID != first.ID { t.Fatalf("second=%v replayed=%v err=%v", second, replayed, err) } _, _, err = service.Request(RequestInput{PaymentID: payment.ID, AmountPaise: 5100, Reason: "Too much remaining", IdempotencyKey: "refund-idem-2", Actor: actor}) @@ -107,24 +108,24 @@ func TestRefundLifecycleRequiresReferenceAndRejectsTerminalTransitions(t *testin if err != nil { t.Fatal(err) } - _, err = service.Update(UpdateInput{RefundID: refund.Id, Status: "completed", Note: "Bank transfer completed.", Actor: actor}) + _, err = service.Update(UpdateInput{RefundID: refund.ID, Status: "completed", Note: "Bank transfer completed.", Actor: actor}) var domainErr *domain.Error if !errors.As(err, &domainErr) || domainErr.Code != "REFUND_REFERENCE_REQUIRED" { t.Fatalf("error=%v", err) } - processing, err := service.Update(UpdateInput{RefundID: refund.Id, Status: "processing", Note: "Initiated in bank app.", Actor: actor}) - if err != nil || processing.GetString("status") != "processing" { + processing, err := service.Update(UpdateInput{RefundID: refund.ID, Status: "processing", Note: "Initiated in bank app.", Actor: actor}) + if err != nil || processing.Status != "processing" { t.Fatalf("processing=%v err=%v", processing, err) } - completed, err := service.Update(UpdateInput{RefundID: refund.Id, Status: "completed", Reference: "RFND123456789", Note: "Verified bank transfer reference.", Actor: actor}) - if err != nil || completed.GetString("status") != "completed" { + completed, err := service.Update(UpdateInput{RefundID: refund.ID, Status: "completed", Reference: "RFND123456789", Note: "Verified bank transfer reference.", Actor: actor}) + if err != nil || completed.Status != "completed" { t.Fatalf("completed=%v err=%v", completed, err) } - _, err = service.Update(UpdateInput{RefundID: refund.Id, Status: "cancelled", Note: "Cannot cancel completed refund.", Actor: actor}) + _, err = service.Update(UpdateInput{RefundID: refund.ID, Status: "cancelled", Note: "Cannot cancel completed refund.", Actor: actor}) if !errors.As(err, &domainErr) || domainErr.Code != "INVALID_REFUND_TRANSITION" { t.Fatalf("terminal error=%v", err) } - stored, _ := app.FindRecordById("refunds", refund.Id) + stored, _ := app.FindRecordById("refunds", refund.ID) if stored.GetString("reference") != "RFND123456789" || stored.GetDateTime("completed_at").IsZero() { t.Fatalf("stored reference=%s completed=%s", stored.GetString("reference"), stored.GetDateTime("completed_at")) } @@ -179,7 +180,7 @@ func TestRefundEventsPersistThroughRealWebhookOutbox(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := service.Update(UpdateInput{RefundID: refund.Id, Status: "processing", Note: "Started", Actor: audit.Actor{ID: operator.Id, Email: operator.Email()}}); err != nil { + if _, err := service.Update(UpdateInput{RefundID: refund.ID, Status: "processing", Note: "Started", Actor: audit.Actor{ID: operator.Id, Email: operator.Email()}}); err != nil { t.Fatal(err) } deliveries, err := app.FindAllRecords("webhook_deliveries") @@ -191,12 +192,12 @@ func TestRefundEventsPersistThroughRealWebhookOutbox(t *testing.T) { switch delivery.GetString("event") { case "refund.requested": foundRequested = true - if delivery.GetString("refund") != refund.Id { + if delivery.GetString("refund") != refund.ID { t.Fatalf("requested delivery refund=%s", delivery.GetString("refund")) } case "refund.processing": foundProcessing = true - if delivery.GetString("refund") != refund.Id { + if delivery.GetString("refund") != refund.ID { t.Fatalf("processing delivery refund=%s", delivery.GetString("refund")) } } @@ -213,22 +214,22 @@ func TestFailedRefundCannotBeReactivatedBeyondRemainingAmount(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := service.Update(UpdateInput{RefundID: first.Id, Status: "failed", Note: "Bank transfer failed", Actor: actor}); err != nil { + if _, err := service.Update(UpdateInput{RefundID: first.ID, Status: "failed", Note: "Bank transfer failed", Actor: actor}); err != nil { t.Fatal(err) } second, _, err := service.Request(RequestInput{PaymentID: payment.ID, AmountPaise: payment.PayablePaise, Reason: "Replacement attempt", Actor: actor}) if err != nil { t.Fatal(err) } - _, err = service.Update(UpdateInput{RefundID: first.Id, Status: "processing", Note: "Unsafe retry", Actor: actor}) + _, err = service.Update(UpdateInput{RefundID: first.ID, Status: "processing", Note: "Unsafe retry", Actor: actor}) var domainErr *domain.Error if !errors.As(err, &domainErr) || domainErr.Code != "REFUND_AMOUNT_EXCEEDS_AVAILABLE" { t.Fatalf("reactivation error=%v", err) } - if _, err := service.Update(UpdateInput{RefundID: second.Id, Status: "cancelled", Note: "Replacement cancelled", Actor: actor}); err != nil { + if _, err := service.Update(UpdateInput{RefundID: second.ID, Status: "cancelled", Note: "Replacement cancelled", Actor: actor}); err != nil { t.Fatal(err) } - if _, err := service.Update(UpdateInput{RefundID: first.Id, Status: "processing", Note: "Retry after capacity released", Actor: actor}); err != nil { + if _, err := service.Update(UpdateInput{RefundID: first.ID, Status: "processing", Note: "Retry after capacity released", Actor: actor}); err != nil { t.Fatalf("retry after capacity released: %v", err) } } diff --git a/internal/reviews/service.go b/internal/reviews/service.go index 89eb6b7..3151604 100644 --- a/internal/reviews/service.go +++ b/internal/reviews/service.go @@ -1,6 +1,7 @@ package reviews import ( + "context" "database/sql" "errors" "strings" @@ -11,12 +12,13 @@ import ( "github.com/Phloraxx/payment-api/internal/paymentemail" "github.com/Phloraxx/payment-api/internal/payments" "github.com/Phloraxx/payment-api/internal/sms" - "github.com/pocketbase/dbx" + "github.com/Phloraxx/payment-api/internal/store" "github.com/pocketbase/pocketbase/core" ) type Service struct { - App core.App + App core.App // transitional adapter for resolve/evidence reads + Store store.Database Payments *payments.Service Audit *audit.Service Now func() time.Time @@ -51,79 +53,52 @@ type ResolveResult struct { } func NewService(app core.App, paymentService *payments.Service, auditService *audit.Service) *Service { - return &Service{App: app, Payments: paymentService, Audit: auditService, Now: time.Now} + return &Service{App: app, Store: store.NewPocketBase(app), Payments: paymentService, Audit: auditService, Now: time.Now} } -func (s *Service) OpenSMSReviewInApp(app core.App, input sms.ReviewInput) (string, error) { - return s.OpenInApp(app, OpenInput{ +func (s *Service) OpenSMSReview(uow store.UnitOfWork, input sms.ReviewInput) (string, error) { + return s.Open(uow, OpenInput{ Kind: input.Kind, Severity: input.Severity, SMSEventID: input.SMSEventID, PaymentID: input.PaymentID, CandidatePaymentIDs: input.CandidatePaymentIDs, Reason: input.Reason, OpenedAt: input.OpenedAt, }) } -func (s *Service) OpenEmailReviewInApp(app core.App, input paymentemail.ReviewInput) (string, error) { - return s.OpenInApp(app, OpenInput{ +func (s *Service) OpenEmailReview(uow store.UnitOfWork, input paymentemail.ReviewInput) (string, error) { + return s.Open(uow, OpenInput{ Kind: input.Kind, Severity: input.Severity, EmailEventID: input.EmailEventID, PaymentID: input.PaymentID, CandidatePaymentIDs: input.CandidatePaymentIDs, Reason: input.Reason, OpenedAt: input.OpenedAt, }) } -func (s *Service) OpenInApp(app core.App, input OpenInput) (string, error) { +func (s *Service) Open(uow store.UnitOfWork, input OpenInput) (string, error) { if input.SMSEventID == "" && input.EmailEventID == "" && input.ReconciliationEntryID == "" { return "", errors.New("evidence record is required for review") } - if input.EmailEventID != "" { - existing, err := app.FindFirstRecordByData("review_cases", "email_event", input.EmailEventID) - if err == nil { - return existing.Id, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return "", err - } - } - if input.SMSEventID != "" { - existing, err := app.FindFirstRecordByData("review_cases", "sms_event", input.SMSEventID) - if err == nil { - return existing.Id, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return "", err - } - } - if input.ReconciliationEntryID != "" { - existing, err := app.FindFirstRecordByData("review_cases", "reconciliation_entry", input.ReconciliationEntryID) - if err == nil { - return existing.Id, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return "", err - } + repo := uow.Reviews() + existing, err := repo.FindByEvidence(input.SMSEventID, input.EmailEventID, input.ReconciliationEntryID) + if err == nil { + return existing.ID, nil } - collection, err := app.FindCollectionByNameOrId("review_cases") - if err != nil { + if !errors.Is(err, sql.ErrNoRows) { return "", err } - now := input.OpenedAt.UTC() - if now.IsZero() { - now = s.now() + openedAt := input.OpenedAt.UTC() + if openedAt.IsZero() { + openedAt = s.now() } - record := core.NewRecord(collection) - record.Set("kind", input.Kind) - record.Set("status", "open") - record.Set("severity", input.Severity) - record.Set("sms_event", input.SMSEventID) - record.Set("email_event", input.EmailEventID) - record.Set("reconciliation_entry", input.ReconciliationEntryID) - record.Set("payment", input.PaymentID) - record.Set("candidate_payment_ids", input.CandidatePaymentIDs) - record.Set("reason", truncate(input.Reason, 4096)) - record.Set("opened_at", now) - if err := app.Save(record); err != nil { + review := &domain.ReviewCase{ + Kind: input.Kind, Status: "open", Severity: input.Severity, + SMSEventID: input.SMSEventID, EmailEventID: input.EmailEventID, + ReconciliationEntryID: input.ReconciliationEntryID, PaymentID: input.PaymentID, + CandidatePaymentIDs: append([]string(nil), input.CandidatePaymentIDs...), + Reason: truncate(input.Reason, 4096), OpenedAt: openedAt, + } + if err := repo.Create(review); err != nil { return "", err } - return record.Id, nil + return review.ID, nil } func (s *Service) Resolve(input ResolveInput) (ResolveResult, error) { @@ -141,15 +116,15 @@ func (s *Service) Resolve(input ResolveInput) (ResolveResult, error) { now := s.now() var result ResolveResult var wake bool - err := s.App.RunInTransaction(func(tx core.App) error { - caseRecord, err := tx.FindRecordById("review_cases", input.CaseID) + err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + review, err := uow.Reviews().Get(input.CaseID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return domain.New("REVIEW_CASE_NOT_FOUND", "review case not found", 404) } return err } - if caseRecord.GetString("status") != "open" { + if review.Status != "open" { return domain.New("REVIEW_CASE_RESOLVED", "review case is already resolved", 409) } @@ -159,46 +134,30 @@ func (s *Service) Resolve(input ResolveInput) (ResolveResult, error) { if input.PaymentID == "" { return domain.New("INVALID_REVIEW_RESOLUTION", "paymentId is required for a manual match", 400) } - eventID := caseRecord.GetString("sms_event") - emailEventID := caseRecord.GetString("email_event") - entryID := caseRecord.GetString("reconciliation_entry") var parsed domain.ParsedSMS - var event *core.Record - var entry *core.Record - var emailEvent *core.Record - if eventID != "" { - event, err = tx.FindRecordById("sms_events", eventID) + var smsEvent *domain.SMSEvent + var emailEvent *domain.EmailEvent + var entry *domain.ReconciliationEntry + switch { + case review.SMSEventID != "": + smsEvent, err = uow.SMSEvents().Get(review.SMSEventID) if err != nil { return err } - parsed = domain.ParsedSMS{ - Account: domain.PaymentAccount(event.GetString("payment_account")), - AmountPaise: int64(event.GetInt("amount")), RRN: event.GetString("rrn"), - UPIId: event.GetString("upi_id"), PayerName: event.GetString("payer_name"), - OccurredAt: event.GetDateTime("message_time").Time(), - } - } else if emailEventID != "" { - emailEvent, err = tx.FindRecordById("email_events", emailEventID) + parsed = domain.ParsedSMS{Account: smsEvent.Account, AmountPaise: smsEvent.AmountPaise, RRN: smsEvent.RRN, UPIId: smsEvent.UPIID, PayerName: smsEvent.PayerName, OccurredAt: smsEvent.MessageTime} + case review.EmailEventID != "": + emailEvent, err = uow.EmailEvents().Get(review.EmailEventID) if err != nil { return err } - parsed = domain.ParsedSMS{ - Account: domain.PaymentAccount(emailEvent.GetString("payment_account")), - AmountPaise: int64(emailEvent.GetInt("amount")), RRN: emailEvent.GetString("rrn"), - UPIId: emailEvent.GetString("upi_id"), PayerName: emailEvent.GetString("payer_name"), - OccurredAt: emailEvent.GetDateTime("message_time").Time(), - } - } else if entryID != "" { - entry, err = tx.FindRecordById("reconciliation_entries", entryID) + parsed = domain.ParsedSMS{Account: emailEvent.Account, AmountPaise: emailEvent.AmountPaise, RRN: emailEvent.RRN, UPIId: emailEvent.UPIID, PayerName: emailEvent.PayerName, OccurredAt: emailEvent.MessageTime} + case review.ReconciliationEntryID != "": + entry, err = uow.ReconciliationEntries().Get(review.ReconciliationEntryID) if err != nil { return err } - parsed = domain.ParsedSMS{ - Account: domain.PaymentAccountKotak, - AmountPaise: int64(entry.GetInt("amount")), RRN: entry.GetString("rrn"), - OccurredAt: entry.GetDateTime("transaction_time").Time(), - } - } else { + parsed = domain.ParsedSMS{Account: domain.PaymentAccountKotak, AmountPaise: entry.AmountPaise, RRN: entry.RRN, OccurredAt: entry.TransactionTime} + default: return domain.New("REVIEW_HAS_NO_EVIDENCE", "review case has no bank evidence", 409) } reference := normalizeReference(parsed.RRN) @@ -209,80 +168,65 @@ func (s *Service) Resolve(input ResolveInput) (ResolveResult, error) { return domain.New("BANK_REFERENCE_REQUIRED", "enter the bank RRN/UTR before manually matching this evidence", 400) } parsed.RRN = reference - payment, action, queued, err := s.Payments.ManualMatchInApp(tx, input.PaymentID, parsed, now) + payment, action, queued, err := s.Payments.ManualMatch(uow, input.PaymentID, parsed, now) if err != nil { return err } wake = wake || queued - if event != nil { - event.Set("rrn", reference) - event.Set("processing_status", "matched") - event.Set("matched_payment", payment.Id) - event.Set("error", "") - if err := tx.Save(event); err != nil { + if smsEvent != nil { + smsEvent.RRN, smsEvent.ProcessingStatus, smsEvent.MatchedPaymentID, smsEvent.Error = reference, "matched", payment.ID, "" + if err := uow.SMSEvents().Save(smsEvent); err != nil { return err } } if emailEvent != nil { - emailEvent.Set("rrn", reference) - emailEvent.Set("processing_status", "matched") - emailEvent.Set("matched_payment", payment.Id) - emailEvent.Set("error", "") - if err := tx.Save(emailEvent); err != nil { + emailEvent.RRN, emailEvent.ProcessingStatus, emailEvent.MatchedPaymentID, emailEvent.Error = reference, "matched", payment.ID, "" + if err := uow.EmailEvents().Save(emailEvent); err != nil { return err } } if entry != nil { - entry.Set("rrn", reference) - entry.Set("status", "matched") - entry.Set("payment", payment.Id) - entry.Set("notes", "Manually reconciled by operator") - if err := tx.Save(entry); err != nil { + entry.RRN, entry.Status, entry.PaymentID, entry.Notes = reference, "matched", payment.ID, "Manually reconciled by operator" + if err := uow.ReconciliationEntries().Save(entry); err != nil { return err } } - caseRecord.Set("payment", payment.Id) - result.PaymentID = payment.Id - result.Action = action + review.PaymentID = payment.ID + result.PaymentID, result.Action = payment.ID, action case "dismissed", "duplicate", "not_payment", "corrected": if input.Action == "dismissed" { - caseRecord.Set("status", "dismissed") + review.Status = "dismissed" } result.Action = input.Action default: return domain.New("INVALID_REVIEW_RESOLUTION", "unsupported review action", 400) } - if caseRecord.GetString("status") != "dismissed" { - caseRecord.Set("status", "resolved") + if review.Status != "dismissed" { + review.Status = "resolved" } - caseRecord.Set("resolution", resolution) - caseRecord.Set("resolution_note", input.Note) - caseRecord.Set("resolved_by", input.Actor.ID) - caseRecord.Set("resolved_at", now) - if err := tx.Save(caseRecord); err != nil { + review.Resolution = resolution + review.ResolutionNote = input.Note + review.ResolvedBy = input.Actor.ID + review.ResolvedAt = now + if err := uow.Reviews().Save(review); err != nil { return err } if s.Audit != nil { - if err := s.Audit.RecordInApp(tx, audit.Entry{ + if err := s.Audit.RecordUoW(uow, audit.Entry{ Action: "review." + resolution, Actor: input.Actor, - EntityType: "review_case", EntityID: caseRecord.Id, + EntityType: "review_case", EntityID: review.ID, Summary: "Operator resolved payment evidence review", Details: map[string]any{ - "paymentId": result.PaymentID, - "smsEventId": caseRecord.GetString("sms_event"), - "emailEventId": caseRecord.GetString("email_event"), - "reconciliationEntryId": caseRecord.GetString("reconciliation_entry"), - "resolution": resolution, - "note": input.Note, - }, - OccurredAt: now, + "paymentId": result.PaymentID, "smsEventId": review.SMSEventID, + "emailEventId": review.EmailEventID, "reconciliationEntryId": review.ReconciliationEntryID, + "resolution": resolution, "note": input.Note, + }, OccurredAt: now, }); err != nil { return err } } - result.CaseID = caseRecord.Id - result.Status = caseRecord.GetString("status") + result.CaseID, result.Status = review.ID, review.Status return nil }) if err != nil { @@ -295,7 +239,13 @@ func (s *Service) Resolve(input ResolveInput) (ResolveResult, error) { } func (s *Service) OpenCount() (int64, error) { - return s.App.CountRecords("review_cases", dbx.NewExp("status = 'open'")) + var count int64 + err := s.Store.View(context.Background(), func(uow store.UnitOfWork) error { + var err error + count, err = uow.Reviews().OpenCount() + return err + }) + return count, err } func (s *Service) now() time.Time { diff --git a/internal/reviews/service_test.go b/internal/reviews/service_test.go index 9621166..1cb1f2a 100644 --- a/internal/reviews/service_test.go +++ b/internal/reviews/service_test.go @@ -11,6 +11,7 @@ import ( "github.com/Phloraxx/payment-api/internal/paymentemail" "github.com/Phloraxx/payment-api/internal/payments" "github.com/Phloraxx/payment-api/internal/sms" + "github.com/Phloraxx/payment-api/internal/store" _ "github.com/Phloraxx/payment-api/migrations" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tests" @@ -97,7 +98,7 @@ func TestResolveManualMatchPersistsPaymentEventCaseAndAudit(t *testing.T) { t.Fatal(err) } eventID := createSMSEvent(t, app, payment.PayablePaise, "", now.Add(time.Minute)) - caseID, err := service.OpenSMSReviewInApp(app, sms.ReviewInput{ + caseID, err := service.OpenSMSReview(store.NewPocketBaseUnit(app), sms.ReviewInput{ Kind: "missing_rrn", Severity: "warning", SMSEventID: eventID, CandidatePaymentIDs: []string{payment.ID}, Reason: "missing reference", OpenedAt: *now, }) @@ -139,7 +140,7 @@ func TestManualMatchRejectsDifferentAmount(t *testing.T) { t.Fatal(err) } eventID := createSMSEvent(t, app, payment.PayablePaise+1, "999988887777", *now) - caseID, err := service.OpenSMSReviewInApp(app, sms.ReviewInput{Kind: "unmatched", Severity: "warning", SMSEventID: eventID, Reason: "amount mismatch"}) + caseID, err := service.OpenSMSReview(store.NewPocketBaseUnit(app), sms.ReviewInput{Kind: "unmatched", Severity: "warning", SMSEventID: eventID, Reason: "amount mismatch"}) if err != nil { t.Fatal(err) } @@ -161,7 +162,7 @@ func TestResolveManualEmailMatchUpdatesEmailEvidence(t *testing.T) { t.Fatal(err) } eventID := createEmailEvent(t, app, payment.PayablePaise, "", now.Add(time.Minute)) - caseID, err := service.OpenEmailReviewInApp(app, paymentemail.ReviewInput{ + caseID, err := service.OpenEmailReview(store.NewPocketBaseUnit(app), paymentemail.ReviewInput{ Kind: "missing_rrn", Severity: "warning", EmailEventID: eventID, CandidatePaymentIDs: []string{payment.ID}, Reason: "missing reference", OpenedAt: *now, }) @@ -188,11 +189,11 @@ func TestResolveManualEmailMatchUpdatesEmailEvidence(t *testing.T) { func TestOpenSMSReviewIsIdempotentPerEvidenceEvent(t *testing.T) { service, _, app, now := reviewTestService(t) eventID := createSMSEvent(t, app, 10001, "", *now) - first, err := service.OpenSMSReviewInApp(app, sms.ReviewInput{Kind: "missing_rrn", Severity: "warning", SMSEventID: eventID, Reason: "missing"}) + first, err := service.OpenSMSReview(store.NewPocketBaseUnit(app), sms.ReviewInput{Kind: "missing_rrn", Severity: "warning", SMSEventID: eventID, Reason: "missing"}) if err != nil { t.Fatal(err) } - second, err := service.OpenSMSReviewInApp(app, sms.ReviewInput{Kind: "missing_rrn", Severity: "warning", SMSEventID: eventID, Reason: "missing again"}) + second, err := service.OpenSMSReview(store.NewPocketBaseUnit(app), sms.ReviewInput{Kind: "missing_rrn", Severity: "warning", SMSEventID: eventID, Reason: "missing again"}) if err != nil || first != second { t.Fatalf("first=%s second=%s err=%v", first, second, err) } diff --git a/internal/sms/service.go b/internal/sms/service.go index d1bbd73..3ec158d 100644 --- a/internal/sms/service.go +++ b/internal/sms/service.go @@ -1,6 +1,7 @@ package sms import ( + "context" "database/sql" "errors" "strings" @@ -9,7 +10,7 @@ import ( "github.com/Phloraxx/payment-api/internal/domain" "github.com/Phloraxx/payment-api/internal/payments" - "github.com/pocketbase/dbx" + "github.com/Phloraxx/payment-api/internal/store" "github.com/pocketbase/pocketbase/core" ) @@ -33,7 +34,7 @@ type ReviewInput struct { } type ReviewWriter interface { - OpenSMSReviewInApp(app core.App, input ReviewInput) (string, error) + OpenSMSReview(uow store.UnitOfWork, input ReviewInput) (string, error) } type Result struct { @@ -46,14 +47,14 @@ type Result struct { } type Service struct { - App core.App + Store store.Database Payments *payments.Service Reviews ReviewWriter Now func() time.Time } func NewService(app core.App, paymentService *payments.Service) *Service { - return &Service{App: app, Payments: paymentService, Now: time.Now} + return &Service{Store: store.NewPocketBase(app), Payments: paymentService, Now: time.Now} } func (s *Service) Ingest(input Input) (Result, error) { @@ -91,13 +92,10 @@ func (s *Service) Ingest(input Input) (Result, error) { var result Result var domainErr error var queued bool - err := s.App.RunInTransaction(func(tx core.App) error { + err := s.Store.Write(context.Background(), func(uow store.UnitOfWork) error { + events := uow.SMSEvents() if input.SourceEventID != "" { - existing, err := tx.FindFirstRecordByFilter( - "sms_events", - "source = {:source} && source_event_id = {:id}", - dbx.Params{"source": input.Source, "id": input.SourceEventID}, - ) + existing, err := events.FindBySourceEvent(input.Source, input.SourceEventID) if err == nil { result = resultFromEvent(existing) result.Action = "duplicate_event" @@ -108,116 +106,77 @@ func (s *Service) Ingest(input Input) (Result, error) { return err } } - - collection, err := tx.FindCollectionByNameOrId("sms_events") - if err != nil { + event := &domain.SMSEvent{Source: input.Source, SourceEventID: input.SourceEventID, Sender: input.Sender, Body: input.Body, Account: domain.PaymentAccountKotak, MessageTime: messageTime, ProcessingStatus: "received", RawPayload: input.RawPayload} + if err := events.Create(event); err != nil { return err } - event := core.NewRecord(collection) - event.Set("source", input.Source) - event.Set("source_event_id", input.SourceEventID) - event.Set("sender", input.Sender) - event.Set("body", input.Body) - event.Set("payment_account", string(domain.PaymentAccountKotak)) - event.Set("processing_status", "received") - event.Set("message_time", messageTime) - if input.RawPayload != nil { - event.Set("raw_payload", input.RawPayload) - } - if err := tx.Save(event); err != nil { - return err - } - result.EventID = event.Id + result.EventID = event.ID parsed, parseErr := Parse(input.Body) parsed.Account = domain.PaymentAccountKotak parsed.OccurredAt = messageTime if errors.Is(parseErr, ErrUnrecognized) { - event.Set("processing_status", "ignored") - event.Set("error", "not a recognized bank credit message") - if err := tx.Save(event); err != nil { + event.ProcessingStatus, event.Error = "ignored", "not a recognized bank credit message" + if err := events.Save(event); err != nil { return err } - result.Status = "ignored" - result.Action = "ignored_non_bank_sms" + result.Status, result.Action = "ignored", "ignored_non_bank_sms" return nil } if parseErr != nil { - event.Set("processing_status", "error") - event.Set("error", parseErr.Error()) - if err := tx.Save(event); err != nil { + event.ProcessingStatus, event.Error = "error", parseErr.Error() + if err := events.Save(event); err != nil { return err } - caseID, err := s.openReviewInApp(tx, ReviewInput{ - Kind: "parse_error", Severity: "warning", SMSEventID: event.Id, - Reason: "Bank-credit-like message could not be parsed: " + parseErr.Error(), OpenedAt: now, - }) + caseID, err := s.openReview(uow, ReviewInput{Kind: "parse_error", Severity: "warning", SMSEventID: event.ID, Reason: "Bank-credit-like message could not be parsed: " + parseErr.Error(), OpenedAt: now}) if err != nil { return err } - result.Status = "review_required" - result.Action = "parse_error" - result.ReviewCaseID = caseID + result.Status, result.Action, result.ReviewCaseID = "review_required", "parse_error", caseID return nil } - event.Set("amount", parsed.AmountPaise) - event.Set("rrn", parsed.RRN) - event.Set("upi_id", parsed.UPIId) - event.Set("payer_name", parsed.PayerName) - event.Set("processing_status", "parsed") + event.AmountPaise, event.RRN, event.UPIID, event.PayerName, event.ProcessingStatus = parsed.AmountPaise, parsed.RRN, parsed.UPIId, parsed.PayerName, "parsed" if strings.TrimSpace(parsed.RRN) == "" { - event.Set("processing_status", "error") - event.Set("error", "bank credit has no usable UPI reference/RRN") - if err := tx.Save(event); err != nil { + event.ProcessingStatus, event.Error = "error", "bank credit has no usable UPI reference/RRN" + if err := events.Save(event); err != nil { return err } - candidates, err := candidatePaymentIDs(tx, parsed.Account, parsed.AmountPaise, now) + candidates, err := candidatePaymentIDs(uow, parsed.Account, parsed.AmountPaise, now) if err != nil { return err } - caseID, err := s.openReviewInApp(tx, ReviewInput{ - Kind: "missing_rrn", Severity: "warning", SMSEventID: event.Id, - CandidatePaymentIDs: candidates, Reason: "Bank credit has an amount but no usable UPI reference/RRN", OpenedAt: now, - }) + caseID, err := s.openReview(uow, ReviewInput{Kind: "missing_rrn", Severity: "warning", SMSEventID: event.ID, CandidatePaymentIDs: candidates, Reason: "Bank credit has an amount but no usable UPI reference/RRN", OpenedAt: now}) if err != nil { return err } - result.Status = "review_required" - result.Action = "missing_rrn" - result.ReviewCaseID = caseID + result.Status, result.Action, result.ReviewCaseID = "review_required", "missing_rrn", caseID return nil } - payment, action, matchQueued, matchErr := s.Payments.MatchInApp(tx, parsed, now) + payment, outcome, matchQueued, matchErr := s.Payments.MatchBankEvidence(uow, parsed, now) + action := string(outcome) queued = queued || matchQueued if matchErr != nil { var dErr *domain.Error if errors.As(matchErr, &dErr) { - event.Set("processing_status", "error") - event.Set("error", dErr.Message) - if err := tx.Save(event); err != nil { + event.ProcessingStatus, event.Error = "error", dErr.Message + if err := events.Save(event); err != nil { return err } kind := "ambiguous" - severity := "critical" if dErr.Code == "RRN_AMOUNT_MISMATCH" || dErr.Code == "RRN_ACCOUNT_MISMATCH" { kind = "rrn_conflict" } - candidates, err := candidatePaymentIDs(tx, parsed.Account, parsed.AmountPaise, now) + candidates, err := candidatePaymentIDs(uow, parsed.Account, parsed.AmountPaise, now) if err != nil { return err } - caseID, err := s.openReviewInApp(tx, ReviewInput{ - Kind: kind, Severity: severity, SMSEventID: event.Id, - CandidatePaymentIDs: candidates, Reason: dErr.Message, OpenedAt: now, - }) + caseID, err := s.openReview(uow, ReviewInput{Kind: kind, Severity: "critical", SMSEventID: event.ID, CandidatePaymentIDs: candidates, Reason: dErr.Message, OpenedAt: now}) if err != nil { return err } - result.Status = "review_required" - result.Action = "match_error" - result.ReviewCaseID = caseID + result.Status, result.Action, result.ReviewCaseID = "review_required", "match_error", caseID return nil } return matchErr @@ -225,40 +184,25 @@ func (s *Service) Ingest(input Input) (Result, error) { switch action { case "marked_paid", "marked_late": - event.Set("processing_status", "matched") - event.Set("matched_payment", payment.Id) - result.Status = "matched" - result.PaymentID = payment.Id + event.ProcessingStatus, event.MatchedPaymentID, result.Status, result.PaymentID = "matched", payment.ID, "matched", payment.ID case "duplicate_rrn": - event.Set("processing_status", "duplicate") - event.Set("matched_payment", payment.Id) - result.Status = "duplicate" - result.PaymentID = payment.Id - result.Duplicate = true + event.ProcessingStatus, event.MatchedPaymentID, result.Status, result.PaymentID, result.Duplicate = "duplicate", payment.ID, "duplicate", payment.ID, true case "unmatched": - event.Set("processing_status", "unmatched") - event.Set("error", "no eligible payment has this exact amount") - candidates, err := candidatePaymentIDs(tx, parsed.Account, parsed.AmountPaise, now) + event.ProcessingStatus, event.Error = "unmatched", "no eligible payment has this exact amount" + candidates, err := candidatePaymentIDs(uow, parsed.Account, parsed.AmountPaise, now) if err != nil { return err } - caseID, err := s.openReviewInApp(tx, ReviewInput{ - Kind: "unmatched", Severity: "warning", SMSEventID: event.Id, - CandidatePaymentIDs: candidates, Reason: "No eligible payment has this exact amount", OpenedAt: now, - }) + caseID, err := s.openReview(uow, ReviewInput{Kind: "unmatched", Severity: "warning", SMSEventID: event.ID, CandidatePaymentIDs: candidates, Reason: "No eligible payment has this exact amount", OpenedAt: now}) if err != nil { return err } - result.Status = "review_required" - result.ReviewCaseID = caseID + result.Status, result.ReviewCaseID = "review_required", caseID default: - event.Set("processing_status", "error") - event.Set("error", "unexpected matching action: "+action) - result.Status = "error" - domainErr = domain.New("INTERNAL_MATCH_STATE", "unexpected matching result", 500) + event.ProcessingStatus, event.Error, result.Status, domainErr = "error", "unexpected matching action: "+action, "error", domain.New("INTERNAL_MATCH_STATE", "unexpected matching result", 500) } result.Action = action - return tx.Save(event) + return events.Save(event) }) if err != nil { return Result{}, err @@ -272,12 +216,8 @@ func (s *Service) Ingest(input Input) (Result, error) { return result, nil } -func resultFromEvent(event *core.Record) Result { - return Result{ - EventID: event.Id, - Status: event.GetString("processing_status"), - PaymentID: event.GetString("matched_payment"), - } +func resultFromEvent(event *domain.SMSEvent) Result { + return Result{EventID: event.ID, Status: event.ProcessingStatus, PaymentID: event.MatchedPaymentID} } func validSource(source string) bool { @@ -289,31 +229,24 @@ func validSource(source string) bool { } } -func (s *Service) openReviewInApp(app core.App, input ReviewInput) (string, error) { +func (s *Service) openReview(uow store.UnitOfWork, input ReviewInput) (string, error) { if s.Reviews == nil { return "", nil } - return s.Reviews.OpenSMSReviewInApp(app, input) + return s.Reviews.OpenSMSReview(uow, input) } -func candidatePaymentIDs(app core.App, account domain.PaymentAccount, amountPaise int64, now time.Time) ([]string, error) { +func candidatePaymentIDs(uow store.UnitOfWork, account domain.PaymentAccount, amountPaise int64, now time.Time) ([]string, error) { if amountPaise <= 0 { return nil, nil } - records, err := app.FindRecordsByFilter( - "payments", - "payment_account = {:account} && payable_amount = {:amount} && reuse_after > {:now}", - "-created_at", - 10, - 0, - dbx.Params{"account": string(account), "amount": amountPaise, "now": now.UTC().Format("2006-01-02 15:04:05.000Z")}, - ) + payments, err := uow.Payments().ListFingerprintCandidates(account, amountPaise, now, 10) if err != nil { return nil, err } - ids := make([]string, 0, len(records)) - for _, record := range records { - ids = append(ids, record.Id) + ids := make([]string, 0, len(payments)) + for _, payment := range payments { + ids = append(ids, payment.ID) } return ids, nil } diff --git a/internal/store/pocketbase.go b/internal/store/pocketbase.go new file mode 100644 index 0000000..0a9ccad --- /dev/null +++ b/internal/store/pocketbase.go @@ -0,0 +1,1127 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/types" +) + +type pocketBaseDatabase struct{ app core.App } +type pocketBaseUnit struct{ app core.App } +type pocketBasePayments struct{ app core.App } +type pocketBaseSMSEvents struct{ app core.App } +type pocketBaseEmailEvents struct{ app core.App } +type pocketBaseReconciliationRuns struct{ app core.App } +type pocketBaseReconciliationEntries struct{ app core.App } +type pocketBaseAudit struct{ app core.App } +type pocketBaseRefunds struct{ app core.App } +type pocketBaseNotificationEvents struct{ app core.App } +type pocketBaseReviews struct{ app core.App } +type pocketBaseRelay struct{ app core.App } +type pocketBaseRelayEvents struct{ app core.App } +type pocketBaseOutbox struct{ app core.App } + +func NewPocketBase(app core.App) Database { return &pocketBaseDatabase{app: app} } + +// NewPocketBaseUnit adapts an already-open PocketBase transaction while +// legacy callers are migrated to Database.Write. +func NewPocketBaseUnit(app core.App) UnitOfWork { return &pocketBaseUnit{app: app} } + +func (db *pocketBaseDatabase) View(_ context.Context, fn func(UnitOfWork) error) error { + return fn(&pocketBaseUnit{app: db.app}) +} + +func (db *pocketBaseDatabase) Write(_ context.Context, fn func(UnitOfWork) error) error { + return db.app.RunInTransaction(func(tx core.App) error { return fn(&pocketBaseUnit{app: tx}) }) +} + +func (u *pocketBaseUnit) Payments() PaymentRepository { return &pocketBasePayments{app: u.app} } +func (u *pocketBaseUnit) SMSEvents() SMSEventRepository { return &pocketBaseSMSEvents{app: u.app} } +func (u *pocketBaseUnit) EmailEvents() EmailEventRepository { + return &pocketBaseEmailEvents{app: u.app} +} +func (u *pocketBaseUnit) ReconciliationRuns() ReconciliationRunRepository { + return &pocketBaseReconciliationRuns{app: u.app} +} +func (u *pocketBaseUnit) ReconciliationEntries() ReconciliationEntryRepository { + return &pocketBaseReconciliationEntries{app: u.app} +} +func (u *pocketBaseUnit) Audit() AuditRepository { return &pocketBaseAudit{app: u.app} } +func (u *pocketBaseUnit) Refunds() RefundRepository { return &pocketBaseRefunds{app: u.app} } +func (u *pocketBaseUnit) NotificationEvents() NotificationEventRepository { + return &pocketBaseNotificationEvents{app: u.app} +} +func (u *pocketBaseUnit) Reviews() ReviewRepository { return &pocketBaseReviews{app: u.app} } +func (u *pocketBaseUnit) Relay() RelayRepository { return &pocketBaseRelay{app: u.app} } +func (u *pocketBaseUnit) RelayEvents() RelayEventRepository { + return &pocketBaseRelayEvents{app: u.app} +} +func (u *pocketBaseUnit) Outbox() OutboxRepository { return &pocketBaseOutbox{app: u.app} } + +func (r *pocketBasePayments) Get(id string) (*domain.Payment, error) { + record, err := r.app.FindRecordById("payments", id) + if err != nil { + return nil, err + } + return paymentFromRecord(record), nil +} + +func (r *pocketBasePayments) FindByIdempotencyKey(key string) (*domain.Payment, error) { + record, err := r.app.FindFirstRecordByData("payments", "idempotency_key", key) + if err != nil { + return nil, err + } + return paymentFromRecord(record), nil +} + +func (r *pocketBasePayments) IsFingerprintBlocked(payableAmount int64, now time.Time) (bool, error) { + record, err := r.app.FindFirstRecordByFilter("payments", "payable_amount = {:amount} && reuse_after > {:now}", dbx.Params{"amount": payableAmount, "now": storeDate(now)}) + if err == nil && record != nil { + return true, nil + } + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, err +} + +func (r *pocketBasePayments) Create(payment NewPayment) (*domain.Payment, error) { + collection, err := r.app.FindCollectionByNameOrId("payments") + if err != nil { + return nil, err + } + record := core.NewRecord(collection) + record.Set("created_at", payment.CreatedAt) + record.Set("payment_account", string(payment.Account)) + record.Set("requested_amount", payment.RequestedPaise) + record.Set("payable_amount", payment.PayablePaise) + record.Set("status", string(domain.StatusPending)) + record.Set("expires_at", payment.ExpiresAt) + record.Set("reuse_after", payment.ReuseAfter) + record.Set("external_id", payment.ExternalID) + record.Set("idempotency_key", payment.IdempotencyKey) + if payment.Metadata != nil { + record.Set("metadata", payment.Metadata) + } + if err := r.app.Save(record); err != nil { + return nil, err + } + return paymentFromRecord(record), nil +} + +func (r *pocketBasePayments) FindByEvidenceReference(kind domain.EvidenceReferenceKind, reference string) (*domain.Payment, error) { + field := "" + switch kind { + case domain.EvidenceReferenceRRN: + field = "rrn" + case domain.EvidenceReferenceRelay: + field = "evidence_reference" + default: + return nil, fmt.Errorf("unsupported evidence reference kind %q", kind) + } + record, err := r.app.FindFirstRecordByData("payments", field, reference) + if err != nil { + return nil, err + } + return paymentFromRecord(record), nil +} + +func (r *pocketBasePayments) FindOnTimeCandidates(account domain.PaymentAccount, amount int64, evidenceAt, createdBefore, now time.Time) ([]*domain.Payment, error) { + records, err := r.app.FindRecordsByFilter( + "payments", + "payment_account = {:account} && payable_amount = {:amount} && created_at <= {:createdBefore} && ((status = 'pending' && expires_at >= {:evidenceAt} && reuse_after > {:now}) || (status = 'expired' && expires_at >= {:evidenceAt} && reuse_after > {:now}) || (status = 'cancelled' && resolved_at != '' && resolved_at >= {:evidenceAt} && reuse_after > {:now}))", + "created", 2, 0, + dbx.Params{"account": string(account), "amount": amount, "now": storeDate(now), "evidenceAt": storeDate(evidenceAt), "createdBefore": storeDate(createdBefore)}, + ) + if err != nil { + return nil, err + } + return paymentsFromRecords(records), nil +} + +func (r *pocketBasePayments) FindLateCandidates(account domain.PaymentAccount, amount int64, evidenceAt, createdBefore, now time.Time) ([]*domain.Payment, error) { + records, err := r.app.FindRecordsByFilter( + "payments", + "payment_account = {:account} && payable_amount = {:amount} && (status = 'expired' || status = 'cancelled' || (status = 'pending' && expires_at < {:evidenceAt})) && reuse_after > {:now} && created_at <= {:createdBefore}", + "-created", 2, 0, + dbx.Params{"account": string(account), "amount": amount, "now": storeDate(now), "evidenceAt": storeDate(evidenceAt), "createdBefore": storeDate(createdBefore)}, + ) + if err != nil { + return nil, err + } + return paymentsFromRecords(records), nil +} + +func (r *pocketBasePayments) Save(payment *domain.Payment) error { + if payment == nil { + return nil + } + record, err := r.app.FindRecordById("payments", payment.ID) + if err != nil { + return err + } + record.Set("payment_account", string(payment.Account)) + record.Set("requested_amount", payment.RequestedPaise) + record.Set("payable_amount", payment.PayablePaise) + record.Set("status", string(payment.Status)) + record.Set("expires_at", payment.ExpiresAt) + record.Set("reuse_after", payment.ReuseAfter) + record.Set("rrn", payment.RRN) + record.Set("upi_id", payment.UPIId) + record.Set("payer_name", payment.PayerName) + record.Set("evidence_source", payment.EvidenceSource) + record.Set("evidence_reference", payment.EvidenceReference) + record.Set("paid_at", payment.PaidAt) + record.Set("resolved_at", payment.ResolvedAt) + record.Set("external_id", payment.ExternalID) + record.Set("idempotency_key", payment.IdempotencyKey) + record.Set("display_name", payment.DisplayName) + record.Set("customer_name", payment.CustomerName) + record.Set("customer_email", payment.CustomerEmail) + record.Set("customer_phone", payment.CustomerPhone) + record.Set("description", payment.Description) + record.Set("admin_note", payment.AdminNote) + record.Set("tags", payment.Tags) + record.Set("custom_fields", payment.CustomFields) + if payment.Metadata != nil { + record.Set("metadata", payment.Metadata) + } else { + record.Set("metadata", nil) + } + return r.app.Save(record) +} + +func (r *pocketBasePayments) ListDue(now time.Time, limit int) ([]*domain.Payment, error) { + if limit <= 0 { + return nil, nil + } + records, err := r.app.FindRecordsByFilter( + "payments", "status = 'pending' && expires_at <= {:now}", "expires_at", limit, 0, + dbx.Params{"now": storeDate(now)}, + ) + if err != nil { + return nil, err + } + return paymentsFromRecords(records), nil +} + +func (r *pocketBasePayments) ListAll() ([]*domain.Payment, error) { + records, err := r.app.FindAllRecords("payments") + if err != nil { + return nil, err + } + return paymentsFromRecords(records), nil +} + +func (r *pocketBasePayments) FindReconciliationCandidates(account domain.PaymentAccount, amount int64, transactionTime, now time.Time, limit int) ([]*domain.Payment, error) { + if amount <= 0 { + return nil, nil + } + if limit <= 0 { + limit = 10 + } + var records []*core.Record + var err error + if !transactionTime.IsZero() { + records, err = r.app.FindRecordsByFilter("payments", "payment_account = {:account} && payable_amount = {:amount} && created_at <= {:createdBefore} && reuse_after >= {:at}", "-created_at", limit, 0, dbx.Params{"account": string(account), "amount": amount, "at": storeDate(transactionTime), "createdBefore": storeDate(transactionTime.Add(2 * time.Second))}) + } else { + records, err = r.app.FindRecordsByFilter("payments", "payment_account = {:account} && payable_amount = {:amount} && reuse_after > {:now}", "-created_at", limit, 0, dbx.Params{"account": string(account), "amount": amount, "now": storeDate(now)}) + } + if err != nil { + return nil, err + } + return paymentsFromRecords(records), nil +} + +func (r *pocketBasePayments) ListBlocked(now time.Time) ([]*domain.Payment, error) { + records, err := r.app.FindRecordsByFilter("payments", "reuse_after > {:now}", "requested_amount,payable_amount", 0, 0, dbx.Params{"now": storeDate(now)}) + if err != nil { + return nil, err + } + return paymentsFromRecords(records), nil +} + +func (r *pocketBasePayments) ListFingerprintCandidates(account domain.PaymentAccount, amount int64, now time.Time, limit int) ([]*domain.Payment, error) { + if amount <= 0 || limit <= 0 { + return nil, nil + } + records, err := r.app.FindRecordsByFilter( + "payments", + "payment_account = {:account} && payable_amount = {:amount} && reuse_after > {:now}", + "-created_at", limit, 0, + dbx.Params{"account": string(account), "amount": amount, "now": storeDate(now)}, + ) + if err != nil { + return nil, err + } + return paymentsFromRecords(records), nil +} + +func paymentsFromRecords(records []*core.Record) []*domain.Payment { + result := make([]*domain.Payment, 0, len(records)) + for _, record := range records { + result = append(result, paymentFromRecord(record)) + } + return result +} + +func paymentFromRecord(record *core.Record) *domain.Payment { + if record == nil { + return nil + } + return &domain.Payment{ + ID: record.Id, Account: domain.PaymentAccount(record.GetString("payment_account")), + RequestedPaise: int64(record.GetInt("requested_amount")), PayablePaise: int64(record.GetInt("payable_amount")), + Status: domain.PaymentStatus(record.GetString("status")), CreatedAt: record.GetDateTime("created_at").Time(), + ExpiresAt: record.GetDateTime("expires_at").Time(), ReuseAfter: record.GetDateTime("reuse_after").Time(), + RRN: record.GetString("rrn"), UPIId: record.GetString("upi_id"), PayerName: record.GetString("payer_name"), + EvidenceSource: record.GetString("evidence_source"), EvidenceReference: record.GetString("evidence_reference"), + PaidAt: record.GetDateTime("paid_at").Time(), ResolvedAt: record.GetDateTime("resolved_at").Time(), + ExternalID: record.GetString("external_id"), IdempotencyKey: record.GetString("idempotency_key"), Metadata: jsonValue(record.Get("metadata")), + DisplayName: record.GetString("display_name"), CustomerName: record.GetString("customer_name"), + CustomerEmail: record.GetString("customer_email"), CustomerPhone: record.GetString("customer_phone"), + Description: record.GetString("description"), AdminNote: record.GetString("admin_note"), + Tags: stringSlice(record.Get("tags")), CustomFields: jsonValue(record.Get("custom_fields")), + } +} + +func (r *pocketBaseSMSEvents) Get(id string) (*domain.SMSEvent, error) { + record, err := r.app.FindRecordById("sms_events", id) + if err != nil { + return nil, err + } + return smsEventFromRecord(record), nil +} + +func (r *pocketBaseSMSEvents) FindBySourceEvent(source, sourceEventID string) (*domain.SMSEvent, error) { + record, err := r.app.FindFirstRecordByFilter("sms_events", "source = {:source} && source_event_id = {:id}", dbx.Params{"source": source, "id": sourceEventID}) + if err != nil { + return nil, err + } + return smsEventFromRecord(record), nil +} + +func (r *pocketBaseSMSEvents) Create(event *domain.SMSEvent) error { + collection, err := r.app.FindCollectionByNameOrId("sms_events") + if err != nil { + return err + } + record := core.NewRecord(collection) + writeSMSEvent(record, event) + if err := r.app.Save(record); err != nil { + return err + } + event.ID = record.Id + return nil +} + +func (r *pocketBaseSMSEvents) Save(event *domain.SMSEvent) error { + record, err := r.app.FindRecordById("sms_events", event.ID) + if err != nil { + return err + } + writeSMSEvent(record, event) + return r.app.Save(record) +} + +func (r *pocketBaseSMSEvents) ListBySourceSince(source string, since time.Time, limit int) ([]*domain.SMSEvent, error) { + if limit <= 0 { + limit = 5000 + } + records, err := r.app.FindRecordsByFilter("sms_events", "source = {:source} && message_time >= {:since}", "message_time", limit, 0, dbx.Params{"source": source, "since": storeDate(since)}) + if err != nil { + return nil, err + } + items := make([]*domain.SMSEvent, 0, len(records)) + for _, record := range records { + items = append(items, smsEventFromRecord(record)) + } + return items, nil +} + +func writeSMSEvent(record *core.Record, event *domain.SMSEvent) { + record.Set("source", event.Source) + record.Set("source_event_id", event.SourceEventID) + record.Set("sender", event.Sender) + record.Set("body", event.Body) + record.Set("payment_account", string(event.Account)) + record.Set("message_time", event.MessageTime) + record.Set("amount", event.AmountPaise) + record.Set("rrn", event.RRN) + record.Set("upi_id", event.UPIID) + record.Set("payer_name", event.PayerName) + record.Set("processing_status", event.ProcessingStatus) + record.Set("matched_payment", event.MatchedPaymentID) + record.Set("error", event.Error) + if event.RawPayload != nil { + record.Set("raw_payload", event.RawPayload) + } +} + +func smsEventFromRecord(record *core.Record) *domain.SMSEvent { + if record == nil { + return nil + } + return &domain.SMSEvent{ID: record.Id, Source: record.GetString("source"), SourceEventID: record.GetString("source_event_id"), Sender: record.GetString("sender"), Body: record.GetString("body"), Account: domain.PaymentAccount(record.GetString("payment_account")), MessageTime: record.GetDateTime("message_time").Time(), AmountPaise: int64(record.GetInt("amount")), RRN: record.GetString("rrn"), UPIID: record.GetString("upi_id"), PayerName: record.GetString("payer_name"), ProcessingStatus: record.GetString("processing_status"), MatchedPaymentID: record.GetString("matched_payment"), Error: record.GetString("error"), RawPayload: record.Get("raw_payload")} +} + +func (r *pocketBaseEmailEvents) Get(id string) (*domain.EmailEvent, error) { + record, err := r.app.FindRecordById("email_events", id) + if err != nil { + return nil, err + } + return emailEventFromRecord(record), nil +} + +func (r *pocketBaseEmailEvents) FindBySourceEvent(source, sourceEventID string) (*domain.EmailEvent, error) { + record, err := r.app.FindFirstRecordByFilter("email_events", "source = {:source} && source_event_id = {:id}", dbx.Params{"source": source, "id": sourceEventID}) + if err != nil { + return nil, err + } + return emailEventFromRecord(record), nil +} + +func (r *pocketBaseEmailEvents) Create(event *domain.EmailEvent) error { + collection, err := r.app.FindCollectionByNameOrId("email_events") + if err != nil { + return err + } + record := core.NewRecord(collection) + writeEmailEvent(record, event) + if err := r.app.Save(record); err != nil { + return err + } + event.ID = record.Id + return nil +} + +func (r *pocketBaseEmailEvents) Save(event *domain.EmailEvent) error { + record, err := r.app.FindRecordById("email_events", event.ID) + if err != nil { + return err + } + writeEmailEvent(record, event) + return r.app.Save(record) +} + +func writeEmailEvent(record *core.Record, event *domain.EmailEvent) { + record.Set("source", event.Source) + record.Set("source_event_id", event.SourceEventID) + record.Set("envelope_sender", event.EnvelopeSender) + record.Set("recipient", event.Recipient) + record.Set("sender", event.Sender) + record.Set("subject", event.Subject) + record.Set("body", event.Body) + record.Set("payment_account", string(event.Account)) + record.Set("message_time", event.MessageTime) + record.Set("received_at", event.ReceivedAt) + record.Set("auth_result", event.AuthResult) + record.Set("amount", event.AmountPaise) + record.Set("rrn", event.RRN) + record.Set("upi_id", event.UPIID) + record.Set("payer_name", event.PayerName) + record.Set("processing_status", event.ProcessingStatus) + record.Set("matched_payment", event.MatchedPaymentID) + record.Set("error", event.Error) + if event.RawPayload != nil { + record.Set("raw_payload", event.RawPayload) + } +} + +func emailEventFromRecord(record *core.Record) *domain.EmailEvent { + if record == nil { + return nil + } + return &domain.EmailEvent{ID: record.Id, Source: record.GetString("source"), SourceEventID: record.GetString("source_event_id"), EnvelopeSender: record.GetString("envelope_sender"), Recipient: record.GetString("recipient"), Sender: record.GetString("sender"), Subject: record.GetString("subject"), Body: record.GetString("body"), Account: domain.PaymentAccount(record.GetString("payment_account")), MessageTime: record.GetDateTime("message_time").Time(), ReceivedAt: record.GetDateTime("received_at").Time(), AuthResult: record.GetString("auth_result"), AmountPaise: int64(record.GetInt("amount")), RRN: record.GetString("rrn"), UPIID: record.GetString("upi_id"), PayerName: record.GetString("payer_name"), ProcessingStatus: record.GetString("processing_status"), MatchedPaymentID: record.GetString("matched_payment"), Error: record.GetString("error"), RawPayload: record.Get("raw_payload")} +} + +func (r *pocketBaseReconciliationRuns) FindCompletedByHash(hash string) (*domain.ReconciliationRun, error) { + record, err := r.app.FindFirstRecordByFilter("reconciliation_runs", "sha256 = {:hash} && status = 'completed'", dbx.Params{"hash": hash}) + if err != nil { + return nil, err + } + return reconciliationRunFromRecord(record), nil +} + +func (r *pocketBaseReconciliationRuns) Get(id string) (*domain.ReconciliationRun, error) { + record, err := r.app.FindRecordById("reconciliation_runs", id) + if err != nil { + return nil, err + } + return reconciliationRunFromRecord(record), nil +} + +func (r *pocketBaseReconciliationRuns) Create(run *domain.ReconciliationRun) error { + collection, err := r.app.FindCollectionByNameOrId("reconciliation_runs") + if err != nil { + return err + } + record := core.NewRecord(collection) + applyReconciliationRunRecord(record, run) + if err := r.app.Save(record); err != nil { + return err + } + run.ID = record.Id + return nil +} + +func (r *pocketBaseReconciliationRuns) Save(run *domain.ReconciliationRun) error { + record, err := r.app.FindRecordById("reconciliation_runs", run.ID) + if err != nil { + return err + } + applyReconciliationRunRecord(record, run) + return r.app.Save(record) +} + +func applyReconciliationRunRecord(record *core.Record, run *domain.ReconciliationRun) { + record.Set("filename", run.Filename) + record.Set("sha256", run.SHA256) + record.Set("status", run.Status) + record.Set("created_by", run.CreatedBy) + record.Set("started_at", run.StartedAt) + record.Set("completed_at", run.CompletedAt) + record.Set("total_rows", run.TotalRows) + record.Set("matched_rows", run.MatchedRows) + record.Set("unmatched_rows", run.UnmatchedRows) + record.Set("duplicate_rows", run.DuplicateRows) + record.Set("conflict_rows", run.ConflictRows) + record.Set("invalid_rows", run.InvalidRows) + record.Set("error", run.Error) + if run.Summary != nil { + record.Set("summary", run.Summary) + } +} + +func reconciliationRunFromRecord(record *core.Record) *domain.ReconciliationRun { + if record == nil { + return nil + } + return &domain.ReconciliationRun{ID: record.Id, Filename: record.GetString("filename"), SHA256: record.GetString("sha256"), Status: record.GetString("status"), CreatedBy: record.GetString("created_by"), StartedAt: record.GetDateTime("started_at").Time(), CompletedAt: record.GetDateTime("completed_at").Time(), TotalRows: record.GetInt("total_rows"), MatchedRows: record.GetInt("matched_rows"), UnmatchedRows: record.GetInt("unmatched_rows"), DuplicateRows: record.GetInt("duplicate_rows"), ConflictRows: record.GetInt("conflict_rows"), InvalidRows: record.GetInt("invalid_rows"), Error: record.GetString("error"), Summary: record.Get("summary")} +} + +func (r *pocketBaseReconciliationEntries) Create(entry *domain.ReconciliationEntry) error { + collection, err := r.app.FindCollectionByNameOrId("reconciliation_entries") + if err != nil { + return err + } + record := core.NewRecord(collection) + record.Set("run", entry.RunID) + record.Set("row_number", entry.RowNumber) + record.Set("transaction_time", entry.TransactionTime) + record.Set("amount", entry.AmountPaise) + record.Set("rrn", entry.RRN) + record.Set("description", entry.Description) + record.Set("status", entry.Status) + record.Set("payment", entry.PaymentID) + record.Set("notes", entry.Notes) + if entry.RawRow != nil { + record.Set("raw_row", entry.RawRow) + } + if err := r.app.Save(record); err != nil { + return err + } + entry.ID = record.Id + return nil +} + +func (r *pocketBaseReconciliationEntries) Get(id string) (*domain.ReconciliationEntry, error) { + record, err := r.app.FindRecordById("reconciliation_entries", id) + if err != nil { + return nil, err + } + return reconciliationEntryFromRecord(record), nil +} + +func (r *pocketBaseReconciliationEntries) Save(entry *domain.ReconciliationEntry) error { + record, err := r.app.FindRecordById("reconciliation_entries", entry.ID) + if err != nil { + return err + } + record.Set("rrn", entry.RRN) + record.Set("status", entry.Status) + record.Set("payment", entry.PaymentID) + record.Set("notes", entry.Notes) + return r.app.Save(record) +} + +func reconciliationEntryFromRecord(record *core.Record) *domain.ReconciliationEntry { + if record == nil { + return nil + } + return &domain.ReconciliationEntry{ID: record.Id, RunID: record.GetString("run"), RowNumber: record.GetInt("row_number"), TransactionTime: record.GetDateTime("transaction_time").Time(), AmountPaise: int64(record.GetInt("amount")), RRN: record.GetString("rrn"), Description: record.GetString("description"), Status: record.GetString("status"), PaymentID: record.GetString("payment"), Notes: record.GetString("notes"), RawRow: record.Get("raw_row")} +} + +func (r *pocketBaseRefunds) Get(id string) (*domain.Refund, error) { + record, err := r.app.FindRecordById("refunds", id) + if err != nil { + return nil, err + } + return refundFromRecord(record), nil +} + +func (r *pocketBaseRefunds) FindByIdempotencyKey(key string) (*domain.Refund, error) { + record, err := r.app.FindFirstRecordByData("refunds", "idempotency_key", key) + if err != nil { + return nil, err + } + return refundFromRecord(record), nil +} + +func (r *pocketBaseRefunds) FindByReference(reference string) (*domain.Refund, error) { + record, err := r.app.FindFirstRecordByData("refunds", "reference", reference) + if err != nil { + return nil, err + } + return refundFromRecord(record), nil +} + +func (r *pocketBaseRefunds) ReservedAmount(paymentID string) (int64, error) { + records, err := r.app.FindRecordsByFilter("refunds", "payment = {:payment} && status != 'cancelled' && status != 'failed'", "created", 0, 0, dbx.Params{"payment": paymentID}) + if err != nil { + return 0, err + } + var total int64 + for _, record := range records { + total += int64(record.GetInt("amount")) + } + return total, nil +} + +func (r *pocketBaseRefunds) Create(refund *domain.Refund) error { + collection, err := r.app.FindCollectionByNameOrId("refunds") + if err != nil { + return err + } + record := core.NewRecord(collection) + applyRefundRecord(record, refund) + if err := r.app.Save(record); err != nil { + return err + } + refund.ID = record.Id + return nil +} + +func (r *pocketBaseRefunds) Save(refund *domain.Refund) error { + record, err := r.app.FindRecordById("refunds", refund.ID) + if err != nil { + return err + } + applyRefundRecord(record, refund) + return r.app.Save(record) +} + +func applyRefundRecord(record *core.Record, refund *domain.Refund) { + record.Set("payment", refund.PaymentID) + record.Set("amount", refund.AmountPaise) + record.Set("status", refund.Status) + record.Set("reason", refund.Reason) + record.Set("reference", refund.Reference) + record.Set("external_id", refund.ExternalID) + record.Set("idempotency_key", refund.IdempotencyKey) + if refund.Metadata != nil { + record.Set("metadata", refund.Metadata) + } + record.Set("requested_by", refund.RequestedBy) + record.Set("requested_at", refund.RequestedAt) + record.Set("completed_at", refund.CompletedAt) +} + +func refundFromRecord(record *core.Record) *domain.Refund { + if record == nil { + return nil + } + return &domain.Refund{ID: record.Id, PaymentID: record.GetString("payment"), AmountPaise: int64(record.GetInt("amount")), Status: record.GetString("status"), Reason: record.GetString("reason"), Reference: record.GetString("reference"), ExternalID: record.GetString("external_id"), IdempotencyKey: record.GetString("idempotency_key"), Metadata: record.Get("metadata"), RequestedBy: record.GetString("requested_by"), RequestedAt: record.GetDateTime("requested_at").Time(), CompletedAt: record.GetDateTime("completed_at").Time()} +} + +func (r *pocketBaseAudit) Record(event domain.AuditEvent) error { + collection, err := r.app.FindCollectionByNameOrId("audit_events") + if err != nil { + return err + } + record := core.NewRecord(collection) + record.Set("action", event.Action) + record.Set("actor_id", event.ActorID) + record.Set("actor_email", event.ActorEmail) + record.Set("entity_type", event.EntityType) + record.Set("entity_id", event.EntityID) + record.Set("summary", event.Summary) + record.Set("occurred_at", event.OccurredAt) + if event.Details != nil { + record.Set("details", event.Details) + } + return r.app.Save(record) +} + +func (r *pocketBaseNotificationEvents) FindBySourceEvent(source, sourceEventID string) (*domain.NotificationEvent, error) { + record, err := r.app.FindFirstRecordByFilter("notification_events", "source = {:source} && source_event_id = {:id}", dbx.Params{"source": source, "id": sourceEventID}) + if err != nil { + return nil, err + } + return notificationEventFromRecord(record), nil +} + +func (r *pocketBaseNotificationEvents) Get(id string) (*domain.NotificationEvent, error) { + record, err := r.app.FindRecordById("notification_events", id) + if err != nil { + return nil, err + } + return notificationEventFromRecord(record), nil +} + +func (r *pocketBaseNotificationEvents) Create(event *domain.NotificationEvent) error { + collection, err := r.app.FindCollectionByNameOrId("notification_events") + if err != nil { + return err + } + record := core.NewRecord(collection) + writeNotificationEvent(record, event) + if err := r.app.Save(record); err != nil { + return err + } + event.ID = record.Id + return nil +} + +func (r *pocketBaseNotificationEvents) Save(event *domain.NotificationEvent) error { + record, err := r.app.FindRecordById("notification_events", event.ID) + if err != nil { + return err + } + writeNotificationEvent(record, event) + return r.app.Save(record) +} + +func writeNotificationEvent(record *core.Record, event *domain.NotificationEvent) { + record.Set("source", event.Source) + record.Set("source_event_id", event.SourceEventID) + record.Set("app_package", event.AppPackage) + record.Set("app_name", event.AppName) + record.Set("title", event.Title) + record.Set("body", event.Body) + record.Set("big_text", event.BigText) + record.Set("channel", event.Channel) + record.Set("notification_time", event.NotificationTime) + record.Set("payment_account", string(event.Account)) + record.Set("amount", event.AmountPaise) + record.Set("payer_name", event.PayerName) + record.Set("processing_status", event.ProcessingStatus) + record.Set("matched_payment", event.MatchedPaymentID) + record.Set("error", event.Error) + if event.RawPayload != nil { + record.Set("raw_payload", event.RawPayload) + } +} + +func notificationEventFromRecord(record *core.Record) *domain.NotificationEvent { + if record == nil { + return nil + } + return &domain.NotificationEvent{ID: record.Id, Source: record.GetString("source"), SourceEventID: record.GetString("source_event_id"), AppPackage: record.GetString("app_package"), AppName: record.GetString("app_name"), Title: record.GetString("title"), Body: record.GetString("body"), BigText: record.GetString("big_text"), Channel: record.GetString("channel"), NotificationTime: record.GetDateTime("notification_time").Time(), Account: domain.PaymentAccount(record.GetString("payment_account")), AmountPaise: int64(record.GetInt("amount")), PayerName: record.GetString("payer_name"), ProcessingStatus: record.GetString("processing_status"), MatchedPaymentID: record.GetString("matched_payment"), Error: record.GetString("error"), RawPayload: record.Get("raw_payload")} +} + +func (r *pocketBaseReviews) FindByEvidence(smsEventID, emailEventID, reconciliationEntryID string) (*domain.ReviewCase, error) { + field, value := "", "" + switch { + case emailEventID != "": + field, value = "email_event", emailEventID + case smsEventID != "": + field, value = "sms_event", smsEventID + case reconciliationEntryID != "": + field, value = "reconciliation_entry", reconciliationEntryID + default: + return nil, sql.ErrNoRows + } + record, err := r.app.FindFirstRecordByData("review_cases", field, value) + if err != nil { + return nil, err + } + return reviewFromRecord(record), nil +} + +func (r *pocketBaseReviews) Create(review *domain.ReviewCase) error { + collection, err := r.app.FindCollectionByNameOrId("review_cases") + if err != nil { + return err + } + record := core.NewRecord(collection) + writeReview(record, review) + if err := r.app.Save(record); err != nil { + return err + } + review.ID = record.Id + return nil +} + +func (r *pocketBaseReviews) Get(id string) (*domain.ReviewCase, error) { + record, err := r.app.FindRecordById("review_cases", id) + if err != nil { + return nil, err + } + return reviewFromRecord(record), nil +} + +func (r *pocketBaseReviews) Save(review *domain.ReviewCase) error { + record, err := r.app.FindRecordById("review_cases", review.ID) + if err != nil { + return err + } + writeReview(record, review) + return r.app.Save(record) +} + +func (r *pocketBaseReviews) OpenCount() (int64, error) { + return r.app.CountRecords("review_cases", dbx.NewExp("status = 'open'")) +} + +func writeReview(record *core.Record, review *domain.ReviewCase) { + record.Set("kind", review.Kind) + record.Set("status", review.Status) + record.Set("severity", review.Severity) + record.Set("sms_event", review.SMSEventID) + record.Set("email_event", review.EmailEventID) + record.Set("reconciliation_entry", review.ReconciliationEntryID) + record.Set("payment", review.PaymentID) + record.Set("candidate_payment_ids", review.CandidatePaymentIDs) + record.Set("reason", review.Reason) + record.Set("resolution", review.Resolution) + record.Set("resolution_note", review.ResolutionNote) + record.Set("resolved_by", review.ResolvedBy) + record.Set("opened_at", review.OpenedAt) + record.Set("resolved_at", review.ResolvedAt) +} + +func reviewFromRecord(record *core.Record) *domain.ReviewCase { + if record == nil { + return nil + } + return &domain.ReviewCase{ID: record.Id, Kind: record.GetString("kind"), Status: record.GetString("status"), Severity: record.GetString("severity"), SMSEventID: record.GetString("sms_event"), EmailEventID: record.GetString("email_event"), ReconciliationEntryID: record.GetString("reconciliation_entry"), PaymentID: record.GetString("payment"), CandidatePaymentIDs: stringSlice(record.Get("candidate_payment_ids")), Reason: record.GetString("reason"), Resolution: record.GetString("resolution"), ResolutionNote: record.GetString("resolution_note"), ResolvedBy: record.GetString("resolved_by"), OpenedAt: record.GetDateTime("opened_at").Time(), ResolvedAt: record.GetDateTime("resolved_at").Time()} +} + +func stringSlice(value any) []string { + switch items := value.(type) { + case []string: + return append([]string(nil), items...) + case []any: + result := make([]string, 0, len(items)) + for _, item := range items { + if text, ok := item.(string); ok && strings.TrimSpace(text) != "" { + result = append(result, text) + } + } + return result + case types.JSONRaw: + var result []string + if len(items) == 0 || json.Unmarshal(items, &result) != nil { + return nil + } + return result + case []byte: + var result []string + if len(items) == 0 || json.Unmarshal(items, &result) != nil { + return nil + } + return result + default: + return nil + } +} + +func jsonValue(value any) any { + switch raw := value.(type) { + case types.JSONRaw: + if len(raw) == 0 { + return nil + } + var decoded any + if json.Unmarshal(raw, &decoded) == nil { + return decoded + } + case []byte: + if len(raw) == 0 { + return nil + } + var decoded any + if json.Unmarshal(raw, &decoded) == nil { + return decoded + } + } + return value +} + +func (r *pocketBaseRelay) EnabledDevices(limit int) ([]domain.RelayDeviceHealth, error) { + if limit <= 0 { + limit = 100 + } + records, err := r.app.FindRecordsByFilter("relay_devices", "enabled = true", "-last_seen_at", limit, 0) + if err != nil { + return nil, err + } + result := make([]domain.RelayDeviceHealth, 0, len(records)) + for _, record := range records { + result = append(result, domain.RelayDeviceHealth{ + Enabled: record.GetBool("enabled"), AppVersion: record.GetString("app_version"), + LastSeenAt: record.GetDateTime("last_seen_at").Time(), LastHeartbeatAt: record.GetDateTime("last_heartbeat_at").Time(), + HeartbeatGraceUntil: record.GetDateTime("heartbeat_grace_until").Time(), + NotificationAccess: record.GetBool("notification_access"), ListenerConnected: record.GetBool("listener_connected"), + PowerHealthReported: record.GetBool("power_health_reported"), BatteryOptimizationExempt: record.GetBool("battery_optimization_exempt"), + BackgroundRestricted: record.GetBool("background_restricted"), ForegroundServiceActive: record.GetBool("foreground_service_active"), + }) + } + return result, nil +} + +func (r *pocketBaseOutbox) Enqueue(delivery OutboxDelivery) error { + collection, err := r.app.FindCollectionByNameOrId("webhook_deliveries") + if err != nil { + return err + } + record := core.NewRecord(collection) + record.Set("event_id", delivery.EventID) + record.Set("event", delivery.Event) + record.Set("payment", delivery.PaymentID) + if delivery.RefundID != "" { + record.Set("refund", delivery.RefundID) + } + record.Set("url", delivery.URL) + record.Set("body", delivery.Body) + record.Set("status", "pending") + record.Set("next_attempt_at", delivery.CreatedAt.UTC()) + return r.app.Save(record) +} + +func storeDate(t time.Time) string { + value, err := types.ParseDateTime(t.UTC()) + if err != nil { + return t.UTC().Format(time.RFC3339Nano) + } + return value.String() +} + +func (r *pocketBaseRelay) Get(id string) (*domain.RelayDevice, error) { + record, err := r.app.FindRecordById("relay_devices", id) + if err != nil { + return nil, err + } + return relayDeviceFromRecord(record), nil +} + +func (r *pocketBaseRelay) FindByDeviceID(deviceID string) (*domain.RelayDevice, error) { + record, err := r.app.FindFirstRecordByFilter("relay_devices", "device_id = {:id}", dbx.Params{"id": deviceID}) + if err != nil { + return nil, err + } + return relayDeviceFromRecord(record), nil +} + +func (r *pocketBaseRelay) Create(device *domain.RelayDevice) error { + collection, err := r.app.FindCollectionByNameOrId("relay_devices") + if err != nil { + return err + } + record := core.NewRecord(collection) + writeRelayDevice(record, device) + if err := r.app.Save(record); err != nil { + return err + } + device.ID = record.Id + device.CreatedAt = record.GetDateTime("created").Time() + return nil +} + +func (r *pocketBaseRelay) Save(device *domain.RelayDevice) error { + record, err := r.app.FindRecordById("relay_devices", device.ID) + if err != nil { + return err + } + writeRelayDevice(record, device) + return r.app.Save(record) +} + +func (r *pocketBaseRelay) All(limit int) ([]*domain.RelayDevice, error) { + if limit <= 0 { + limit = 100 + } + records, err := r.app.FindRecordsByFilter("relay_devices", "", "-last_seen_at,-created", limit, 0) + if err != nil { + return nil, err + } + result := make([]*domain.RelayDevice, 0, len(records)) + for _, record := range records { + result = append(result, relayDeviceFromRecord(record)) + } + return result, nil +} + +func writeRelayDevice(record *core.Record, device *domain.RelayDevice) { + record.Set("device_id", device.DeviceID) + record.Set("name", device.Name) + record.Set("public_key_pem", device.PublicKeyPEM) + record.Set("enabled", device.Enabled) + record.Set("app_version", device.AppVersion) + record.Set("android_version", device.AndroidVersion) + record.Set("device_model", device.DeviceModel) + record.Set("enrolled_at", device.EnrolledAt) + record.Set("last_seen_at", device.LastSeenAt) + record.Set("last_heartbeat_at", device.LastHeartbeatAt) + record.Set("heartbeat_grace_until", device.HeartbeatGraceUntil) + record.Set("notification_access", device.NotificationAccess) + record.Set("listener_connected", device.ListenerConnected) + record.Set("power_health_reported", device.PowerHealthReported) + record.Set("battery_optimization_exempt", device.BatteryOptimizationExempt) + record.Set("power_save_mode", device.PowerSaveMode) + record.Set("background_restricted", device.BackgroundRestricted) + record.Set("foreground_service_active", device.ForegroundServiceActive) + record.Set("pending_count", device.PendingCount) + record.Set("failed_count", device.FailedCount) + record.Set("last_client_error", device.LastClientError) + record.Set("last_client_delivery_at", device.LastClientDeliveryAt) +} + +func relayDeviceFromRecord(record *core.Record) *domain.RelayDevice { + if record == nil { + return nil + } + return &domain.RelayDevice{ + ID: record.Id, DeviceID: record.GetString("device_id"), Name: record.GetString("name"), PublicKeyPEM: record.GetString("public_key_pem"), Enabled: record.GetBool("enabled"), + AppVersion: record.GetString("app_version"), AndroidVersion: record.GetString("android_version"), DeviceModel: record.GetString("device_model"), + EnrolledAt: record.GetDateTime("enrolled_at").Time(), LastSeenAt: record.GetDateTime("last_seen_at").Time(), LastHeartbeatAt: record.GetDateTime("last_heartbeat_at").Time(), HeartbeatGraceUntil: record.GetDateTime("heartbeat_grace_until").Time(), + NotificationAccess: record.GetBool("notification_access"), ListenerConnected: record.GetBool("listener_connected"), PowerHealthReported: record.GetBool("power_health_reported"), BatteryOptimizationExempt: record.GetBool("battery_optimization_exempt"), PowerSaveMode: record.GetBool("power_save_mode"), BackgroundRestricted: record.GetBool("background_restricted"), ForegroundServiceActive: record.GetBool("foreground_service_active"), + PendingCount: record.GetInt("pending_count"), FailedCount: record.GetInt("failed_count"), LastClientError: record.GetString("last_client_error"), LastClientDeliveryAt: record.GetDateTime("last_client_delivery_at").Time(), CreatedAt: record.GetDateTime("created").Time(), + } +} + +func (r *pocketBaseRelayEvents) FindByDeviceEvent(deviceRecordID, eventID string) (*domain.RelayEvent, error) { + record, err := r.app.FindFirstRecordByFilter("relay_events", "device = {:device} && event_id = {:event}", dbx.Params{"device": deviceRecordID, "event": eventID}) + if err != nil { + return nil, err + } + return relayEventFromRecord(record), nil +} + +func (r *pocketBaseRelayEvents) Create(event *domain.RelayEvent) error { + collection, err := r.app.FindCollectionByNameOrId("relay_events") + if err != nil { + return err + } + record := core.NewRecord(collection) + writeRelayEvent(record, event) + if err := r.app.Save(record); err != nil { + return err + } + event.ID = record.Id + event.CreatedAt = record.GetDateTime("created").Time() + return nil +} + +func (r *pocketBaseRelayEvents) Save(event *domain.RelayEvent) error { + record, err := r.app.FindRecordById("relay_events", event.ID) + if err != nil { + return err + } + writeRelayEvent(record, event) + return r.app.Save(record) +} + +func (r *pocketBaseRelayEvents) Latest(deviceRecordID string) (*domain.RelayEvent, error) { + filter, params := "", dbx.Params{} + if deviceRecordID != "" { + filter, params = "device = {:device}", dbx.Params{"device": deviceRecordID} + } + records, err := r.app.FindRecordsByFilter("relay_events", filter, "-created", 1, 0, params) + if err != nil { + return nil, err + } + if len(records) == 0 { + return nil, sql.ErrNoRows + } + return relayEventFromRecord(records[0]), nil +} + +func (r *pocketBaseRelayEvents) LatestMatched(deviceRecordID string) (*domain.RelayEvent, error) { + filter := "matched_payment != ''" + params := dbx.Params{} + if deviceRecordID != "" { + filter = "device = {:device} && matched_payment != ''" + params = dbx.Params{"device": deviceRecordID} + } + records, err := r.app.FindRecordsByFilter("relay_events", filter, "-created", 1, 0, params) + if err != nil { + return nil, err + } + if len(records) == 0 { + return nil, sql.ErrNoRows + } + return relayEventFromRecord(records[0]), nil +} + +func (r *pocketBaseRelayEvents) CountErrorsSince(deviceRecordID string, since time.Time) (int64, error) { + if deviceRecordID == "" { + return r.app.CountRecords("relay_events", dbx.NewExp("processing_status = 'error' AND created >= {:cutoff}", dbx.Params{"cutoff": storeDate(since)})) + } + return r.app.CountRecords("relay_events", dbx.NewExp("device = {:device} AND processing_status = 'error' AND created >= {:cutoff}", dbx.Params{"device": deviceRecordID, "cutoff": storeDate(since)})) +} + +func (r *pocketBaseRelayEvents) ListByPackageSince(appPackage string, since time.Time, limit int) ([]*domain.RelayEvent, error) { + if limit <= 0 { + limit = 5000 + } + records, err := r.app.FindRecordsByFilter("relay_events", "app_package = {:package} && created >= {:since}", "created", limit, 0, dbx.Params{"package": appPackage, "since": storeDate(since)}) + if err != nil { + return nil, err + } + items := make([]*domain.RelayEvent, 0, len(records)) + for _, record := range records { + items = append(items, relayEventFromRecord(record)) + } + return items, nil +} + +func writeRelayEvent(record *core.Record, event *domain.RelayEvent) { + record.Set("device", event.DeviceRecordID) + record.Set("event_id", event.EventID) + record.Set("kind", event.Kind) + record.Set("app_package", event.AppPackage) + record.Set("app_name", event.AppName) + record.Set("notification_key", event.NotificationKey) + record.Set("notification_id", event.NotificationID) + record.Set("notification_tag", event.NotificationTag) + record.Set("group_key", event.GroupKey) + record.Set("is_group_summary", event.IsGroupSummary) + record.Set("post_time", event.PostTime) + record.Set("notification_when", event.NotificationWhen) + record.Set("captured_at", event.CapturedAt) + record.Set("channel_id", event.ChannelID) + record.Set("category", event.Category) + record.Set("title", event.Title) + record.Set("body", event.Body) + record.Set("big_text", event.BigText) + record.Set("sub_text", event.SubText) + record.Set("summary_text", event.SummaryText) + record.Set("text_lines", event.TextLines) + record.Set("custom_texts", event.CustomTexts) + record.Set("processing_status", event.ProcessingStatus) + record.Set("downstream_event_id", event.DownstreamEventID) + record.Set("matched_payment", event.MatchedPaymentID) + record.Set("provider_result", event.ProviderResult) + record.Set("error", event.Error) + if event.RawPayload != nil { + record.Set("raw_payload", event.RawPayload) + } +} + +func relayEventFromRecord(record *core.Record) *domain.RelayEvent { + if record == nil { + return nil + } + return &domain.RelayEvent{ID: record.Id, DeviceRecordID: record.GetString("device"), EventID: record.GetString("event_id"), Kind: record.GetString("kind"), AppPackage: record.GetString("app_package"), AppName: record.GetString("app_name"), NotificationKey: record.GetString("notification_key"), NotificationID: record.GetInt("notification_id"), NotificationTag: record.GetString("notification_tag"), GroupKey: record.GetString("group_key"), IsGroupSummary: record.GetBool("is_group_summary"), PostTime: record.GetDateTime("post_time").Time(), NotificationWhen: record.GetDateTime("notification_when").Time(), CapturedAt: record.GetDateTime("captured_at").Time(), ChannelID: record.GetString("channel_id"), Category: record.GetString("category"), Title: record.GetString("title"), Body: record.GetString("body"), BigText: record.GetString("big_text"), SubText: record.GetString("sub_text"), SummaryText: record.GetString("summary_text"), TextLines: stringSlice(record.Get("text_lines")), CustomTexts: stringSlice(record.Get("custom_texts")), ProcessingStatus: record.GetString("processing_status"), DownstreamEventID: record.GetString("downstream_event_id"), MatchedPaymentID: record.GetString("matched_payment"), ProviderResult: record.Get("provider_result"), Error: record.GetString("error"), RawPayload: record.Get("raw_payload"), CreatedAt: record.GetDateTime("created").Time()} +} diff --git a/internal/store/pocketbase_test.go b/internal/store/pocketbase_test.go new file mode 100644 index 0000000..9ead517 --- /dev/null +++ b/internal/store/pocketbase_test.go @@ -0,0 +1,244 @@ +package store + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/domain" + _ "github.com/Phloraxx/payment-api/migrations" + "github.com/pocketbase/pocketbase/tests" +) + +func testDatabase(t *testing.T) (*tests.TestApp, Database) { + t.Helper() + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + t.Cleanup(app.Cleanup) + return app, NewPocketBase(app) +} + +func TestEvidenceRepositoriesRoundTrip(t *testing.T) { + _, db := testDatabase(t) + now := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC) + var smsID, emailID string + err := db.Write(context.Background(), func(uow UnitOfWork) error { + sms := &domain.SMSEvent{Source: "manual", SourceEventID: "sms-1", Body: "credit", Account: domain.PaymentAccountKotak, MessageTime: now, ProcessingStatus: "received"} + if err := uow.SMSEvents().Create(sms); err != nil { + return err + } + sms.ProcessingStatus = "matched" + sms.RRN = "123456789012" + if err := uow.SMSEvents().Save(sms); err != nil { + return err + } + email := &domain.EmailEvent{Source: "manual", SourceEventID: "mail-1", Sender: "bank@example.com", Account: domain.PaymentAccountSlice, MessageTime: now, ReceivedAt: now, ProcessingStatus: "received"} + if err := uow.EmailEvents().Create(email); err != nil { + return err + } + smsID, emailID = sms.ID, email.ID + return nil + }) + if err != nil { + t.Fatal(err) + } + if smsID == "" || emailID == "" { + t.Fatal("repository did not assign record ids") + } + if err := db.View(context.Background(), func(uow UnitOfWork) error { + sms, err := uow.SMSEvents().FindBySourceEvent("manual", "sms-1") + if err != nil { + return err + } + if sms.ID != smsID || sms.ProcessingStatus != "matched" || sms.RRN != "123456789012" { + t.Fatalf("sms=%+v", sms) + } + email, err := uow.EmailEvents().FindBySourceEvent("manual", "mail-1") + if err != nil { + return err + } + if email.ID != emailID || email.Account != domain.PaymentAccountSlice { + t.Fatalf("email=%+v", email) + } + return nil + }); err != nil { + t.Fatal(err) + } +} + +func TestUnitOfWorkRollsBackEvidenceAndReview(t *testing.T) { + app, db := testDatabase(t) + sentinel := errors.New("rollback") + err := db.Write(context.Background(), func(uow UnitOfWork) error { + sms := &domain.SMSEvent{Source: "manual", SourceEventID: "rollback-sms", Body: "credit", Account: domain.PaymentAccountKotak, ProcessingStatus: "received", MessageTime: time.Now().UTC()} + if err := uow.SMSEvents().Create(sms); err != nil { + return err + } + review := &domain.ReviewCase{Kind: "unmatched", Status: "open", Severity: "warning", SMSEventID: sms.ID, Reason: "rollback test", OpenedAt: time.Now().UTC()} + if err := uow.Reviews().Create(review); err != nil { + return err + } + return sentinel + }) + if !errors.Is(err, sentinel) { + t.Fatalf("error=%v", err) + } + if count, _ := app.CountRecords("sms_events"); count != 0 { + t.Fatalf("sms count=%d after rollback", count) + } + if count, _ := app.CountRecords("review_cases"); count != 0 { + t.Fatalf("review count=%d after rollback", count) + } +} + +func TestReviewRepositoryFindsEvidenceCaseAndCountsOpen(t *testing.T) { + _, db := testDatabase(t) + var smsID, reviewID string + if err := db.Write(context.Background(), func(uow UnitOfWork) error { + sms := &domain.SMSEvent{Source: "manual", SourceEventID: "review-sms", Body: "credit", Account: domain.PaymentAccountKotak, ProcessingStatus: "received", MessageTime: time.Now().UTC()} + if err := uow.SMSEvents().Create(sms); err != nil { + return err + } + smsID = sms.ID + review := &domain.ReviewCase{Kind: "unmatched", Status: "open", Severity: "warning", SMSEventID: sms.ID, Reason: "needs review", OpenedAt: time.Now().UTC()} + if err := uow.Reviews().Create(review); err != nil { + return err + } + reviewID = review.ID + return nil + }); err != nil { + t.Fatal(err) + } + if err := db.View(context.Background(), func(uow UnitOfWork) error { + review, err := uow.Reviews().FindByEvidence(smsID, "", "") + if err != nil { + return err + } + if review.ID != reviewID { + t.Fatalf("review id=%s want %s", review.ID, reviewID) + } + count, err := uow.Reviews().OpenCount() + if err != nil { + return err + } + if count != 1 { + t.Fatalf("open count=%d", count) + } + return nil + }); err != nil { + t.Fatal(err) + } +} + +func TestRelayDeviceAndEventRoundTrip(t *testing.T) { + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + defer app.Cleanup() + db := NewPocketBase(app) + now := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC) + var device *domain.RelayDevice + if err := db.Write(context.Background(), func(uow UnitOfWork) error { + device = &domain.RelayDevice{DeviceID: strings.Repeat("a", 64), Name: "Phone", PublicKeyPEM: "key", Enabled: true, EnrolledAt: now, LastSeenAt: now} + if err := uow.Relay().Create(device); err != nil { + return err + } + event := &domain.RelayEvent{DeviceRecordID: device.ID, EventID: strings.Repeat("b", 64), Kind: "notification", AppPackage: "com.paytm.business", ProcessingStatus: "received", CapturedAt: now} + return uow.RelayEvents().Create(event) + }); err != nil { + t.Fatal(err) + } + if err := db.View(context.Background(), func(uow UnitOfWork) error { + stored, err := uow.Relay().FindByDeviceID(device.DeviceID) + if err != nil { + return err + } + if stored.ID != device.ID || stored.Name != "Phone" || !stored.Enabled { + t.Fatalf("stored device=%+v", stored) + } + event, err := uow.RelayEvents().FindByDeviceEvent(device.ID, strings.Repeat("b", 64)) + if err != nil { + return err + } + if event.ProcessingStatus != "received" || event.DeviceRecordID != device.ID { + t.Fatalf("event=%+v", event) + } + return nil + }); err != nil { + t.Fatal(err) + } +} + +func TestRelayEventRollsBackWithUnitOfWork(t *testing.T) { + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + defer app.Cleanup() + db := NewPocketBase(app) + rollback := errors.New("rollback relay") + err = db.Write(context.Background(), func(uow UnitOfWork) error { + device := &domain.RelayDevice{DeviceID: strings.Repeat("c", 64), Name: "Phone", PublicKeyPEM: "key", Enabled: true} + if err := uow.Relay().Create(device); err != nil { + return err + } + if err := uow.RelayEvents().Create(&domain.RelayEvent{DeviceRecordID: device.ID, EventID: strings.Repeat("d", 64), Kind: "notification", AppPackage: "com.paytm.business", ProcessingStatus: "received"}); err != nil { + return err + } + return rollback + }) + if !errors.Is(err, rollback) { + t.Fatalf("err=%v", err) + } + if count, _ := app.CountRecords("relay_devices"); count != 0 { + t.Fatalf("devices=%d", count) + } + if count, _ := app.CountRecords("relay_events"); count != 0 { + t.Fatalf("events=%d", count) + } +} + +func TestShadowHistoryQueriesRoundTrip(t *testing.T) { + _, db := testDatabase(t) + now := time.Now().UTC() + var deviceID string + if err := db.Write(context.Background(), func(uow UnitOfWork) error { + sms := &domain.SMSEvent{Source: "gmessages", SourceEventID: "shadow-sms", Body: "credit", Account: domain.PaymentAccountKotak, MessageTime: now, AmountPaise: 10001, RRN: "123456789012", ProcessingStatus: "matched"} + if err := uow.SMSEvents().Create(sms); err != nil { + return err + } + device := &domain.RelayDevice{DeviceID: strings.Repeat("e", 64), Name: "Phone", PublicKeyPEM: "key", Enabled: true} + if err := uow.Relay().Create(device); err != nil { + return err + } + deviceID = device.ID + event := &domain.RelayEvent{DeviceRecordID: device.ID, EventID: strings.Repeat("f", 64), Kind: "notification", AppPackage: "com.google.android.apps.messaging", ProcessingStatus: "shadow_observed", NotificationWhen: now, ProviderResult: map[string]any{"provider": "google_messages_android_shadow", "parseStatus": "complete", "amountPaise": 10001}} + return uow.RelayEvents().Create(event) + }); err != nil { + t.Fatal(err) + } + if err := db.View(context.Background(), func(uow UnitOfWork) error { + smsItems, err := uow.SMSEvents().ListBySourceSince("gmessages", now.Add(-time.Minute), 10) + if err != nil { + return err + } + if len(smsItems) != 1 || smsItems[0].RRN != "123456789012" { + t.Fatalf("sms items=%+v", smsItems) + } + relayItems, err := uow.RelayEvents().ListByPackageSince("com.google.android.apps.messaging", now.Add(-time.Minute), 10) + if err != nil { + return err + } + if len(relayItems) != 1 || relayItems[0].DeviceRecordID != deviceID || relayItems[0].ProcessingStatus != "shadow_observed" { + t.Fatalf("relay items=%+v", relayItems) + } + return nil + }); err != nil { + t.Fatal(err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..e16748a --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,148 @@ +package store + +import ( + "context" + "time" + + "github.com/Phloraxx/payment-api/internal/domain" +) + +// Database exposes typed repository access without leaking the persistence +// framework into application logic. Write is transaction-scoped; View uses the +// current committed database state. +type Database interface { + View(context.Context, func(UnitOfWork) error) error + Write(context.Context, func(UnitOfWork) error) error +} + +type UnitOfWork interface { + Payments() PaymentRepository + SMSEvents() SMSEventRepository + EmailEvents() EmailEventRepository + ReconciliationRuns() ReconciliationRunRepository + ReconciliationEntries() ReconciliationEntryRepository + Audit() AuditRepository + Refunds() RefundRepository + NotificationEvents() NotificationEventRepository + Reviews() ReviewRepository + Relay() RelayRepository + RelayEvents() RelayEventRepository + Outbox() OutboxRepository +} + +type PaymentRepository interface { + Get(id string) (*domain.Payment, error) + FindByIdempotencyKey(key string) (*domain.Payment, error) + IsFingerprintBlocked(payableAmount int64, now time.Time) (bool, error) + Create(payment NewPayment) (*domain.Payment, error) + Save(payment *domain.Payment) error + FindByEvidenceReference(kind domain.EvidenceReferenceKind, reference string) (*domain.Payment, error) + FindOnTimeCandidates(account domain.PaymentAccount, amount int64, evidenceAt, createdBefore, now time.Time) ([]*domain.Payment, error) + FindLateCandidates(account domain.PaymentAccount, amount int64, evidenceAt, createdBefore, now time.Time) ([]*domain.Payment, error) + ListDue(now time.Time, limit int) ([]*domain.Payment, error) + ListAll() ([]*domain.Payment, error) + ListBlocked(now time.Time) ([]*domain.Payment, error) + ListFingerprintCandidates(account domain.PaymentAccount, amount int64, now time.Time, limit int) ([]*domain.Payment, error) + FindReconciliationCandidates(account domain.PaymentAccount, amount int64, transactionTime, now time.Time, limit int) ([]*domain.Payment, error) +} + +type NewPayment struct { + Account domain.PaymentAccount + RequestedPaise int64 + PayablePaise int64 + CreatedAt time.Time + ExpiresAt time.Time + ReuseAfter time.Time + ExternalID string + IdempotencyKey string + Metadata any +} + +type SMSEventRepository interface { + Get(id string) (*domain.SMSEvent, error) + FindBySourceEvent(source, sourceEventID string) (*domain.SMSEvent, error) + Create(event *domain.SMSEvent) error + Save(event *domain.SMSEvent) error + ListBySourceSince(source string, since time.Time, limit int) ([]*domain.SMSEvent, error) +} + +type EmailEventRepository interface { + Get(id string) (*domain.EmailEvent, error) + FindBySourceEvent(source, sourceEventID string) (*domain.EmailEvent, error) + Create(event *domain.EmailEvent) error + Save(event *domain.EmailEvent) error +} + +type ReconciliationRunRepository interface { + FindCompletedByHash(hash string) (*domain.ReconciliationRun, error) + Get(id string) (*domain.ReconciliationRun, error) + Create(run *domain.ReconciliationRun) error + Save(run *domain.ReconciliationRun) error +} + +type ReconciliationEntryRepository interface { + Get(id string) (*domain.ReconciliationEntry, error) + Create(entry *domain.ReconciliationEntry) error + Save(entry *domain.ReconciliationEntry) error +} + +type AuditRepository interface { + Record(event domain.AuditEvent) error +} + +type RefundRepository interface { + Get(id string) (*domain.Refund, error) + FindByIdempotencyKey(key string) (*domain.Refund, error) + FindByReference(reference string) (*domain.Refund, error) + ReservedAmount(paymentID string) (int64, error) + Create(refund *domain.Refund) error + Save(refund *domain.Refund) error +} + +type NotificationEventRepository interface { + FindBySourceEvent(source, sourceEventID string) (*domain.NotificationEvent, error) + Get(id string) (*domain.NotificationEvent, error) + Create(event *domain.NotificationEvent) error + Save(event *domain.NotificationEvent) error +} + +type ReviewRepository interface { + FindByEvidence(smsEventID, emailEventID, reconciliationEntryID string) (*domain.ReviewCase, error) + Create(review *domain.ReviewCase) error + Get(id string) (*domain.ReviewCase, error) + Save(review *domain.ReviewCase) error + OpenCount() (int64, error) +} + +type RelayRepository interface { + Get(id string) (*domain.RelayDevice, error) + FindByDeviceID(deviceID string) (*domain.RelayDevice, error) + Create(device *domain.RelayDevice) error + Save(device *domain.RelayDevice) error + All(limit int) ([]*domain.RelayDevice, error) + EnabledDevices(limit int) ([]domain.RelayDeviceHealth, error) +} + +type RelayEventRepository interface { + FindByDeviceEvent(deviceRecordID, eventID string) (*domain.RelayEvent, error) + Create(event *domain.RelayEvent) error + Save(event *domain.RelayEvent) error + Latest(deviceRecordID string) (*domain.RelayEvent, error) + LatestMatched(deviceRecordID string) (*domain.RelayEvent, error) + CountErrorsSince(deviceRecordID string, since time.Time) (int64, error) + ListByPackageSince(appPackage string, since time.Time, limit int) ([]*domain.RelayEvent, error) +} + +type OutboxDelivery struct { + EventID string + Event string + PaymentID string + RefundID string + URL string + Body string + CreatedAt time.Time +} + +type OutboxRepository interface { + Enqueue(OutboxDelivery) error +} diff --git a/internal/webhooks/service.go b/internal/webhooks/service.go index 5aee021..f3c10c4 100644 --- a/internal/webhooks/service.go +++ b/internal/webhooks/service.go @@ -16,10 +16,11 @@ import ( "time" "github.com/Phloraxx/payment-api/internal/config" - "github.com/pocketbase/dbx" + "github.com/Phloraxx/payment-api/internal/deliveryqueue" + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/Phloraxx/payment-api/internal/store" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/security" - "github.com/pocketbase/pocketbase/tools/types" ) const maxAttempts = 8 @@ -45,80 +46,83 @@ func NewService(app core.App, cfg config.Config) *Service { } } +func (s *Service) queue() deliveryqueue.Queue { + return deliveryqueue.Queue{ + App: s.App, Collection: "webhook_deliveries", MaxAttempts: maxAttempts, + Fields: deliveryqueue.Fields{ + Status: "status", Attempts: "attempts", NextAttemptAt: "next_attempt_at", + LockedAt: "locked_at", LastAttemptAt: "last_attempt_at", DeliveredAt: "delivered_at", + LastError: "last_error", ResponseCode: "response_code", + }, + RetryDelays: []time.Duration{time.Minute, 5 * time.Minute, 30 * time.Minute, 2 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour}, + StaleAfter: 2 * time.Minute, ExhaustedAfter: 365 * 24 * time.Hour, ErrorMax: 4000, + StaleMessage: "recovered stale delivery lease after restart", + } +} + func (s *Service) Enabled() bool { return s != nil && strings.TrimSpace(s.Config.OutgoingWebhookURL) != "" && s.Config.OutgoingWebhookSecret != "" } -func (s *Service) Schedule(app core.App, event string, payment *core.Record, at time.Time) error { +func (s *Service) SchedulePayment(uow store.UnitOfWork, event string, payment *domain.Payment, at time.Time) error { if payment == nil { return errors.New("payment is required for webhook scheduling") } - return s.schedule(app, event, payment, nil, at) + if !s.Enabled() { + return nil + } + eventID := "evt_" + security.RandomString(24) + paidAt := "" + if !payment.PaidAt.IsZero() { + paidAt = payment.PaidAt.UTC().Format(time.RFC3339Nano) + } + body, err := json.Marshal(map[string]any{ + "id": eventID, "type": event, "createdAt": at.UTC().Format(time.RFC3339Nano), + "data": map[string]any{"payment": map[string]any{ + "id": payment.ID, "paymentAccount": payment.Account, + "requestedAmountPaise": payment.RequestedPaise, "payableAmountPaise": payment.PayablePaise, + "status": payment.Status, "rrn": payment.RRN, "upiId": payment.UPIId, + "payerName": payment.PayerName, "paidAt": paidAt, "externalId": payment.ExternalID, + }}, + }) + if err != nil { + return fmt.Errorf("marshal webhook payload: %w", err) + } + return uow.Outbox().Enqueue(store.OutboxDelivery{ + EventID: eventID, Event: event, PaymentID: payment.ID, + URL: s.Config.OutgoingWebhookURL, Body: string(body), CreatedAt: at.UTC(), + }) } -func (s *Service) ScheduleRefund(app core.App, event string, payment, refund *core.Record, at time.Time) error { +func (s *Service) ScheduleRefundPayment(uow store.UnitOfWork, event string, payment *domain.Payment, refund *domain.Refund, at time.Time) error { if payment == nil || refund == nil { return errors.New("payment and refund are required for refund webhook scheduling") } - return s.schedule(app, event, payment, refund, at) -} - -func (s *Service) schedule(app core.App, event string, payment, refund *core.Record, at time.Time) error { if !s.Enabled() { return nil } - collection, err := app.FindCollectionByNameOrId("webhook_deliveries") - if err != nil { - return err - } eventID := "evt_" + security.RandomString(24) - data := map[string]any{ - "payment": map[string]any{ - "id": payment.Id, - "paymentAccount": payment.GetString("payment_account"), - "requestedAmountPaise": payment.GetInt("requested_amount"), - "payableAmountPaise": payment.GetInt("payable_amount"), - "status": payment.GetString("status"), - "rrn": payment.GetString("rrn"), - "upiId": payment.GetString("upi_id"), - "payerName": payment.GetString("payer_name"), - "paidAt": payment.GetDateTime("paid_at").String(), - "externalId": payment.GetString("external_id"), - }, + paidAt, requestedAt, completedAt := "", "", "" + if !payment.PaidAt.IsZero() { + paidAt = payment.PaidAt.UTC().Format(time.RFC3339Nano) } - if refund != nil { - data["refund"] = map[string]any{ - "id": refund.Id, - "amountPaise": refund.GetInt("amount"), - "status": refund.GetString("status"), - "reason": refund.GetString("reason"), - "reference": refund.GetString("reference"), - "externalId": refund.GetString("external_id"), - "requestedAt": refund.GetDateTime("requested_at").String(), - "completedAt": refund.GetDateTime("completed_at").String(), - } + if !refund.RequestedAt.IsZero() { + requestedAt = refund.RequestedAt.UTC().Format(time.RFC3339Nano) + } + if !refund.CompletedAt.IsZero() { + completedAt = refund.CompletedAt.UTC().Format(time.RFC3339Nano) } body, err := json.Marshal(map[string]any{ - "id": eventID, "type": event, - "createdAt": at.UTC().Format(time.RFC3339Nano), - "data": data, + "id": eventID, "type": event, "createdAt": at.UTC().Format(time.RFC3339Nano), + "data": map[string]any{ + "payment": map[string]any{"id": payment.ID, "paymentAccount": payment.Account, "requestedAmountPaise": payment.RequestedPaise, "payableAmountPaise": payment.PayablePaise, "status": payment.Status, "rrn": payment.RRN, "upiId": payment.UPIId, "payerName": payment.PayerName, "paidAt": paidAt, "externalId": payment.ExternalID}, + "refund": map[string]any{"id": refund.ID, "amountPaise": refund.AmountPaise, "status": refund.Status, "reason": refund.Reason, "reference": refund.Reference, "externalId": refund.ExternalID, "requestedAt": requestedAt, "completedAt": completedAt}, + }, }) if err != nil { return fmt.Errorf("marshal webhook payload: %w", err) } - record := core.NewRecord(collection) - record.Set("event_id", eventID) - record.Set("event", event) - record.Set("payment", payment.Id) - if refund != nil { - record.Set("refund", refund.Id) - } - record.Set("url", s.Config.OutgoingWebhookURL) - record.Set("body", string(body)) - record.Set("attempts", 0) - record.Set("status", "pending") - record.Set("next_attempt_at", at.UTC()) - return app.Save(record) + return uow.Outbox().Enqueue(store.OutboxDelivery{EventID: eventID, Event: event, PaymentID: payment.ID, RefundID: refund.ID, URL: s.Config.OutgoingWebhookURL, Body: string(body), CreatedAt: at.UTC()}) } func (s *Service) Wake() { @@ -156,17 +160,11 @@ func (s *Service) SendPending(ctx context.Context) (int, error) { return 0, nil } now := s.now() - if err := s.recoverStale(now); err != nil { + queue := s.queue() + if err := queue.RecoverStale(now, 50); err != nil { return 0, err } - records, err := s.App.FindRecordsByFilter( - "webhook_deliveries", - "(status = 'pending' || status = 'failed') && next_attempt_at <= {:now}", - "next_attempt_at,created", - 50, - 0, - dbx.Params{"now": filterDate(now)}, - ) + records, err := queue.Due(now, 50) if err != nil { return 0, err } @@ -175,7 +173,7 @@ func (s *Service) SendPending(ctx context.Context) (int, error) { if err := ctx.Err(); err != nil { return processed, err } - claimed, err := s.claim(record.Id, now) + claimed, err := queue.Claim(record.Id, now) if err != nil { s.logger().Warn("failed to claim webhook delivery", "id", record.Id, "error", err) continue @@ -189,33 +187,6 @@ func (s *Service) SendPending(ctx context.Context) (int, error) { return processed, nil } -func (s *Service) claim(id string, now time.Time) (*core.Record, error) { - var claimed *core.Record - err := s.App.RunInTransaction(func(tx core.App) error { - record, err := tx.FindRecordById("webhook_deliveries", id) - if err != nil { - return err - } - status := record.GetString("status") - if status != "pending" && status != "failed" { - return nil - } - if next := record.GetDateTime("next_attempt_at").Time(); !next.IsZero() && next.After(now) { - return nil - } - record.Set("status", "sending") - record.Set("locked_at", now) - record.Set("last_attempt_at", now) - record.Set("attempts", record.GetInt("attempts")+1) - if err := tx.Save(record); err != nil { - return err - } - claimed = record.Clone() - return nil - }) - return claimed, err -} - func (s *Service) deliver(ctx context.Context, record *core.Record) { body := record.GetString("body") timestamp := strconv.FormatInt(s.now().Unix(), 10) @@ -242,66 +213,11 @@ func (s *Service) deliver(ctx context.Context, record *core.Record) { } } } - if finishErr := s.finish(record.Id, statusCode, err); finishErr != nil { + if finishErr := s.queue().Finish(record.Id, s.now(), statusCode, err, nil); finishErr != nil { s.logger().Error("failed to persist webhook result", "id", record.Id, "error", finishErr) } } -func (s *Service) finish(id string, statusCode int, deliveryErr error) error { - now := s.now() - return s.App.RunInTransaction(func(tx core.App) error { - record, err := tx.FindRecordById("webhook_deliveries", id) - if err != nil { - return err - } - record.Set("locked_at", "") - record.Set("response_code", statusCode) - if deliveryErr == nil { - record.Set("status", "delivered") - record.Set("delivered_at", now) - record.Set("last_error", "") - return tx.Save(record) - } - - attempts := record.GetInt("attempts") - record.Set("last_error", truncate(deliveryErr.Error(), 4000)) - if attempts >= maxAttempts { - record.Set("status", "exhausted") - // Keep a valid date for the required field; exhausted records aren't queried. - record.Set("next_attempt_at", now.Add(365*24*time.Hour)) - } else { - record.Set("status", "failed") - record.Set("next_attempt_at", now.Add(retryDelay(attempts))) - } - return tx.Save(record) - }) -} - -func (s *Service) recoverStale(now time.Time) error { - stale := now.Add(-2 * time.Minute) - records, err := s.App.FindRecordsByFilter( - "webhook_deliveries", - "status = 'sending' && locked_at < {:stale}", - "locked_at", - 50, - 0, - dbx.Params{"stale": filterDate(stale)}, - ) - if err != nil { - return err - } - for _, record := range records { - record.Set("status", "failed") - record.Set("locked_at", "") - record.Set("next_attempt_at", now) - record.Set("last_error", "recovered stale delivery lease after restart") - if err := s.App.Save(record); err != nil { - return err - } - } - return nil -} - func Sign(secret, timestamp string, body []byte) string { mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(timestamp)) @@ -310,26 +226,6 @@ func Sign(secret, timestamp string, body []byte) string { return hex.EncodeToString(mac.Sum(nil)) } -func retryDelay(attempt int) time.Duration { - delays := []time.Duration{time.Minute, 5 * time.Minute, 30 * time.Minute, 2 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour} - index := attempt - 1 - if index < 0 { - index = 0 - } - if index >= len(delays) { - return delays[len(delays)-1] - } - return delays[index] -} - -func filterDate(t time.Time) string { - value, err := types.ParseDateTime(t.UTC()) - if err != nil { - return t.UTC().Format(time.RFC3339Nano) - } - return value.String() -} - func (s *Service) now() time.Time { if s.Now == nil { return time.Now().UTC() @@ -359,10 +255,3 @@ func (s *Service) logger() *slog.Logger { } return s.Logger } - -func truncate(value string, max int) string { - if len(value) <= max { - return value - } - return value[:max] -} diff --git a/internal/webhooks/service_test.go b/internal/webhooks/service_test.go index 2b3b039..849afeb 100644 --- a/internal/webhooks/service_test.go +++ b/internal/webhooks/service_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "sync" "sync/atomic" "testing" @@ -12,6 +13,7 @@ import ( "github.com/Phloraxx/payment-api/internal/config" "github.com/Phloraxx/payment-api/internal/payments" + "github.com/Phloraxx/payment-api/internal/store" _ "github.com/Phloraxx/payment-api/migrations" "github.com/pocketbase/pocketbase/tests" ) @@ -38,6 +40,20 @@ func createWebhookTestPayment(t *testing.T, app *tests.TestApp, cfg config.Confi return payment.ID } +func scheduleWebhookTestPayment(t *testing.T, service *Service, app *tests.TestApp, event, paymentID string, at time.Time) { + t.Helper() + db := store.NewPocketBase(app) + if err := db.Write(context.Background(), func(uow store.UnitOfWork) error { + payment, err := uow.Payments().Get(paymentID) + if err != nil { + return err + } + return service.SchedulePayment(uow, event, payment, at) + }); err != nil { + t.Fatal(err) + } +} + func TestSignIsStableHMAC(t *testing.T) { got := Sign("secret", "123", []byte(`{"ok":true}`)) want := "12f14ade5e7e737164d9ae20ea4e070056a3045b2c8f42f5f216008eae4684dd" @@ -61,12 +77,9 @@ func TestWebhookDeliveryPersistsSuccessAndSignature(t *testing.T) { defer server.Close() cfg := config.Config{PaymentTTL: time.Minute, AmountQuarantine: time.Hour, OutgoingWebhookURL: server.URL, OutgoingWebhookSecret: "secret"} paymentID := createWebhookTestPayment(t, app, cfg, now) - payment, _ := app.FindRecordById("payments", paymentID) service := NewService(app, cfg) service.Now = func() time.Time { return now } - if err := service.Schedule(app, "payment.paid", payment, now); err != nil { - t.Fatal(err) - } + scheduleWebhookTestPayment(t, service, app, "payment.paid", paymentID, now) processed, err := service.SendPending(context.Background()) if err != nil || processed != 1 { t.Fatalf("SendPending() = %d, %v", processed, err) @@ -83,6 +96,45 @@ func TestWebhookDeliveryPersistsSuccessAndSignature(t *testing.T) { } } +func TestTypedPaymentScheduleUsesUnitOfWorkOutbox(t *testing.T) { + app := webhookTestApp(t) + now := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC) + cfg := config.Config{PaymentTTL: time.Minute, AmountQuarantine: time.Hour, OutgoingWebhookURL: "https://example.invalid/paygate", OutgoingWebhookSecret: "typed-secret"} + paymentService := payments.NewService(app, cfg, nil) + paymentService.Now = func() time.Time { return now } + paymentService.SuffixStart = func() (int64, error) { return 1, nil } + payment, _, err := paymentService.Create(payments.CreateInput{AmountRupees: 100, ExternalID: "typed-uow"}) + if err != nil { + t.Fatal(err) + } + payment.Status = "cancelled" + payment.ResolvedAt = now + service := NewService(app, cfg) + db := store.NewPocketBase(app) + if err := db.Write(context.Background(), func(uow store.UnitOfWork) error { + if err := uow.Payments().Save(payment); err != nil { + return err + } + return service.SchedulePayment(uow, "payment.cancelled", payment, now) + }); err != nil { + t.Fatal(err) + } + records, err := app.FindAllRecords("webhook_deliveries") + if err != nil || len(records) != 1 { + t.Fatalf("deliveries=%d err=%v", len(records), err) + } + record := records[0] + if record.GetString("event") != "payment.cancelled" || record.GetString("payment") != payment.ID || record.GetString("status") != "pending" { + t.Fatalf("delivery event=%s payment=%s status=%s", record.GetString("event"), record.GetString("payment"), record.GetString("status")) + } + body := record.GetString("body") + for _, want := range []string{`"externalId":"typed-uow"`, `"status":"cancelled"`, `"payableAmountPaise":10001`} { + if !strings.Contains(body, want) { + t.Fatalf("typed webhook body missing %s: %s", want, body) + } + } +} + func TestWebhookRetryIsDurableAndEventuallySucceeds(t *testing.T) { app := webhookTestApp(t) now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) @@ -98,12 +150,9 @@ func TestWebhookRetryIsDurableAndEventuallySucceeds(t *testing.T) { defer server.Close() cfg := config.Config{PaymentTTL: time.Minute, AmountQuarantine: time.Hour, OutgoingWebhookURL: server.URL, OutgoingWebhookSecret: "secret"} paymentID := createWebhookTestPayment(t, app, cfg, now) - payment, _ := app.FindRecordById("payments", paymentID) service := NewService(app, cfg) service.Now = func() time.Time { return now } - if err := service.Schedule(app, "payment.paid", payment, now); err != nil { - t.Fatal(err) - } + scheduleWebhookTestPayment(t, service, app, "payment.paid", paymentID, now) if _, err := service.SendPending(context.Background()); err != nil { t.Fatal(err) } @@ -135,12 +184,9 @@ func TestConcurrentWebhookPassesClaimDeliveryOnce(t *testing.T) { defer server.Close() cfg := config.Config{PaymentTTL: time.Minute, AmountQuarantine: time.Hour, OutgoingWebhookURL: server.URL, OutgoingWebhookSecret: "secret"} paymentID := createWebhookTestPayment(t, app, cfg, now) - payment, _ := app.FindRecordById("payments", paymentID) service := NewService(app, cfg) service.Now = func() time.Time { return now } - if err := service.Schedule(app, "payment.paid", payment, now); err != nil { - t.Fatal(err) - } + scheduleWebhookTestPayment(t, service, app, "payment.paid", paymentID, now) var wg sync.WaitGroup for range 2 { @@ -168,12 +214,9 @@ func TestWebhookClientDoesNotFollowRedirects(t *testing.T) { defer redirector.Close() cfg := config.Config{PaymentTTL: time.Minute, AmountQuarantine: time.Hour, OutgoingWebhookURL: redirector.URL, OutgoingWebhookSecret: "redirect-secret"} paymentID := createWebhookTestPayment(t, app, cfg, now) - payment, _ := app.FindRecordById("payments", paymentID) service := NewService(app, cfg) service.Now = func() time.Time { return now } - if err := service.Schedule(app, "payment.paid", payment, now); err != nil { - t.Fatal(err) - } + scheduleWebhookTestPayment(t, service, app, "payment.paid", paymentID, now) if _, err := service.SendPending(context.Background()); err != nil { t.Fatal(err) } diff --git a/migrations/20260828010000_relay_power_health.go b/migrations/20260828010000_relay_power_health.go new file mode 100644 index 0000000..a6ef675 --- /dev/null +++ b/migrations/20260828010000_relay_power_health.go @@ -0,0 +1,42 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + pbmigrations "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + pbmigrations.Register(func(app core.App) error { + devices, err := app.FindCollectionByNameOrId("relay_devices") + if err != nil { + return err + } + for _, name := range []string{ + "power_health_reported", + "battery_optimization_exempt", + "power_save_mode", + "background_restricted", + "foreground_service_active", + } { + if devices.Fields.GetByName(name) == nil { + devices.Fields.Add(&core.BoolField{Name: name}) + } + } + return app.Save(devices) + }, func(app core.App) error { + devices, err := app.FindCollectionByNameOrId("relay_devices") + if err != nil { + return nil + } + for _, name := range []string{ + "power_health_reported", + "battery_optimization_exempt", + "power_save_mode", + "background_restricted", + "foreground_service_active", + } { + devices.Fields.RemoveByName(name) + } + return app.Save(devices) + }) +} diff --git a/migrations/20260828020000_alert_condition_kinds.go b/migrations/20260828020000_alert_condition_kinds.go new file mode 100644 index 0000000..9b49c60 --- /dev/null +++ b/migrations/20260828020000_alert_condition_kinds.go @@ -0,0 +1,51 @@ +package migrations + +import ( + "fmt" + + "github.com/pocketbase/pocketbase/core" + pbmigrations "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + pbmigrations.Register(func(app core.App) error { + alerts, err := app.FindCollectionByNameOrId("alerts") + if err != nil { + return err + } + kind, ok := alerts.Fields.GetByName("kind").(*core.SelectField) + if !ok || kind == nil { + return fmt.Errorf("alerts.kind is not a select field") + } + if !containsSelectValue(kind.Values, "relay_unavailable") { + kind.Values = append(kind.Values, "relay_unavailable") + } + return app.Save(alerts) + }, func(app core.App) error { + alerts, err := app.FindCollectionByNameOrId("alerts") + if err != nil { + return nil + } + kind, ok := alerts.Fields.GetByName("kind").(*core.SelectField) + if !ok || kind == nil { + return nil + } + values := kind.Values[:0] + for _, value := range kind.Values { + if value != "relay_unavailable" { + values = append(values, value) + } + } + kind.Values = values + return app.Save(alerts) + }) +} + +func containsSelectValue(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/migrations/20260828030000_google_messages_shadow.go b/migrations/20260828030000_google_messages_shadow.go new file mode 100644 index 0000000..6af3ea3 --- /dev/null +++ b/migrations/20260828030000_google_messages_shadow.go @@ -0,0 +1,42 @@ +package migrations + +import ( + "fmt" + + "github.com/pocketbase/pocketbase/core" + pbmigrations "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + pbmigrations.Register(func(app core.App) error { + events, err := app.FindCollectionByNameOrId("relay_events") + if err != nil { + return err + } + status, ok := events.Fields.GetByName("processing_status").(*core.SelectField) + if !ok || status == nil { + return fmt.Errorf("relay_events.processing_status is not a select field") + } + if !containsSelectValue(status.Values, "shadow_observed") { + status.Values = append(status.Values, "shadow_observed") + } + return app.Save(events) + }, func(app core.App) error { + events, err := app.FindCollectionByNameOrId("relay_events") + if err != nil { + return nil + } + status, ok := events.Fields.GetByName("processing_status").(*core.SelectField) + if !ok || status == nil { + return nil + } + values := status.Values[:0] + for _, value := range status.Values { + if value != "shadow_observed" { + values = append(values, value) + } + } + status.Values = values + return app.Save(events) + }) +} diff --git a/migrations/20260829133500_relay_power_cutover_grace.go b/migrations/20260829133500_relay_power_cutover_grace.go new file mode 100644 index 0000000..14b144a --- /dev/null +++ b/migrations/20260829133500_relay_power_cutover_grace.go @@ -0,0 +1,51 @@ +package migrations + +import ( + "time" + + "github.com/pocketbase/pocketbase/core" + pbmigrations "github.com/pocketbase/pocketbase/migrations" +) + +// Bridge already-heartbeating relay devices across the first server version +// that persists power-health telemetry. The old API accepted v0.3.1 +// heartbeats but discarded those fields, so requiring them immediately after +// the server restart creates a false-negative readiness window. +func init() { + pbmigrations.Register(func(app core.App) error { + records, err := app.FindRecordsByFilter( + "relay_devices", + "enabled = true && power_health_reported = false", + "created", 0, 0, + ) + if err != nil { + return err + } + now := time.Now().UTC() + freshCutoff := now.Add(-time.Hour) + graceUntil := now.Add(2 * time.Hour) + for _, record := range records { + lastHeartbeat := record.GetDateTime("last_heartbeat_at").Time() + lastSeen := record.GetDateTime("last_seen_at").Time() + if lastHeartbeat.IsZero() || lastSeen.IsZero() || lastSeen.Before(freshCutoff) { + continue + } + if !record.GetBool("notification_access") || !record.GetBool("listener_connected") { + continue + } + currentGrace := record.GetDateTime("heartbeat_grace_until").Time() + if !currentGrace.IsZero() && currentGrace.After(now) { + continue + } + record.Set("heartbeat_grace_until", graceUntil) + if err := app.Save(record); err != nil { + return err + } + } + return nil + }, func(app core.App) error { + // Operational grace is intentionally not rewound. It is bounded by time + // and by the normal one-hour heartbeat freshness requirement. + return nil + }) +} diff --git a/migrations/20260829161000_payment_admin_profile.go b/migrations/20260829161000_payment_admin_profile.go new file mode 100644 index 0000000..8fbcbfc --- /dev/null +++ b/migrations/20260829161000_payment_admin_profile.go @@ -0,0 +1,47 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + pbmigrations "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + pbmigrations.Register(func(app core.App) error { + payments, err := app.FindCollectionByNameOrId("payments") + if err != nil { + return err + } + fields := []core.Field{ + &core.TextField{Name: "display_name", Max: 255}, + &core.TextField{Name: "customer_name", Max: 255}, + &core.TextField{Name: "customer_email", Max: 254}, + &core.TextField{Name: "customer_phone", Max: 64}, + &core.TextField{Name: "description", Max: 4096}, + &core.TextField{Name: "admin_note", Max: 4096}, + &core.JSONField{Name: "tags", MaxSize: 64 * 1024}, + &core.JSONField{Name: "custom_fields", MaxSize: 256 * 1024}, + } + for _, field := range fields { + if payments.Fields.GetByName(field.GetName()) == nil { + payments.Fields.Add(field) + } + } + payments.AddIndex("idx_payments_customer_email", false, "customer_email", "customer_email != ''") + payments.AddIndex("idx_payments_customer_phone", false, "customer_phone", "customer_phone != ''") + return app.Save(payments) + }, func(app core.App) error { + payments, err := app.FindCollectionByNameOrId("payments") + if err != nil { + return nil + } + payments.RemoveIndex("idx_payments_customer_email") + payments.RemoveIndex("idx_payments_customer_phone") + for _, name := range []string{ + "display_name", "customer_name", "customer_email", "customer_phone", + "description", "admin_note", "tags", "custom_fields", + } { + payments.Fields.RemoveByName(name) + } + return app.Save(payments) + }) +} diff --git a/migrations/migration_test.go b/migrations/migration_test.go index 1da3bfe..f81ccb1 100644 --- a/migrations/migration_test.go +++ b/migrations/migration_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tests" ) @@ -58,4 +59,21 @@ func TestDomainCollectionsOnlyExposeReadsToOperatorUsers(t *testing.T) { if reviews.Fields.GetByName("email_event") == nil { t.Fatal("review_cases.email_event migration field is missing") } + relayDevices, err := app.FindCollectionByNameOrId("relay_devices") + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"power_health_reported", "battery_optimization_exempt", "power_save_mode", "background_restricted", "foreground_service_active"} { + if relayDevices.Fields.GetByName(name) == nil { + t.Fatalf("relay_devices.%s migration field is missing", name) + } + } + relayEvents, err := app.FindCollectionByNameOrId("relay_events") + if err != nil { + t.Fatal(err) + } + status, ok := relayEvents.Fields.GetByName("processing_status").(*core.SelectField) + if !ok || !containsSelectValue(status.Values, "shadow_observed") { + t.Fatal("relay_events.processing_status shadow_observed value is missing") + } } diff --git a/package-lock.json b/package-lock.json index 341c6e2..f76890b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,10 @@ "name": "paygate-ui", "version": "1.0.0", "dependencies": { - "pocketbase": "0.27.0", - "qrcode": "1.5.4", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@types/qrcode": "1.5.6", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "6.0.4", @@ -367,20 +364,12 @@ "version": "22.19.19", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "undici-types": "~6.21.0" } }, - "node_modules/@types/qrcode": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", - "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/react": { "version": "19.2.17", "dev": true, @@ -763,82 +752,11 @@ } } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, "node_modules/csstype": { "version": "3.2.3", "dev": true, "license": "MIT" }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -849,18 +767,6 @@ "node": ">=8" } }, - "node_modules/dijkstrajs": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", - "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -879,19 +785,6 @@ } } }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -907,24 +800,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1186,18 +1061,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -1217,51 +1080,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1282,21 +1100,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pngjs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", - "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/pocketbase": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.27.0.tgz", - "integrity": "sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==", - "license": "MIT" - }, "node_modules/postcss": { "version": "8.5.23", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", @@ -1326,23 +1129,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/qrcode": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", - "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", - "license": "MIT", - "dependencies": { - "dijkstrajs": "^1.0.1", - "pngjs": "^5.0.0", - "yargs": "^15.3.1" - }, - "bin": { - "qrcode": "bin/qrcode" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/react": { "version": "19.2.8", "license": "MIT", @@ -1360,21 +1146,6 @@ "react": "^19.2.8" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "license": "ISC" - }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -1413,12 +1184,6 @@ "version": "0.27.0", "license": "MIT" }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1429,32 +1194,6 @@ "node": ">=0.10.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1518,7 +1257,9 @@ "node_modules/undici-types": { "version": "6.21.0", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/vite": { "version": "8.1.5", @@ -1597,67 +1338,6 @@ "optional": true } } - }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "license": "ISC" - }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" - }, - "node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } } } } diff --git a/package.json b/package.json index c664dc1..a772f8f 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,10 @@ "test": "npm run typecheck && node --test connectors/cloudflare-email-worker/worker.test.mjs && npm run build" }, "dependencies": { - "pocketbase": "0.27.0", - "qrcode": "1.5.4", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@types/qrcode": "1.5.6", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "6.0.4", diff --git a/scripts/v2-host-preflight.sh b/scripts/v2-host-preflight.sh new file mode 100755 index 0000000..f399dac --- /dev/null +++ b/scripts/v2-host-preflight.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +SERVICE="${PAYGATE_SERVICE:-main-payment-17aqux}" +API_ORIGIN="${PAYGATE_ORIGIN:-https://pay.mulearnscet.in}" +BACKUP_DIR="${PAYGATE_BACKUP_DIR:-/home/drvij/paygate-backups/daily}" +EXPECTED_IMAGE="${PAYGATE_EXPECTED_IMAGE:-}" +CHECKOUT="${1:-disabled}" +ALLOWED_ORIGIN="${2:-https://payment.mulearnscet.in}" + +fail() { echo "preflight failed: $*" >&2; exit 1; } +code() { curl -sS -o /tmp/paygate-v2-preflight-body -w '%{http_code}' --max-time 10 "$@"; } + +health="$(curl -fsS --max-time 10 "$API_ORIGIN/api/paygate/health")" +python3 - "$health" <<'PY' +import json,sys +body=json.loads(sys.argv[1]) +assert body.get('db') == 'ok', body +assert body.get('ready') is True, body +assert body.get('relay',{}).get('ready') is True, body +print('live_health=ready') +PY + +[[ "$(code -X POST -H "Content-Type: application/json" --data "{}" "$API_ORIGIN/api/payments")" == "401" ]] || fail "trusted payments endpoint is not anonymous-401" +qr_code="$(code -X POST "$API_ORIGIN/api/connector/gmessages/pair/qr")" +[[ "$qr_code" == "401" || "$qr_code" == "404" ]] || fail "unexpected QR route status: $qr_code" + +service_json="$(docker service inspect "$SERVICE")" +python3 - "$service_json" "$EXPECTED_IMAGE" <<'PY' +import json,sys +svc=json.loads(sys.argv[1])[0] +expected=sys.argv[2].strip() +mode=svc['Spec'].get('Mode',{}).get('Replicated',{}) +assert mode.get('Replicas') == 1, mode +container=svc['Spec']['TaskTemplate']['ContainerSpec'] +mounts=container.get('Mounts',[]) +assert any(m.get('Type')=='volume' and m.get('Target')=='/app/pb_data' for m in mounts), mounts +image=container.get('Image','').strip() +assert image and not image.endswith(':latest'), f'unpinned production image: {image}' +if expected: + assert image == expected, f'production image {image!r} != expected {expected!r}' +print('service_shape=single_replica_persistent_volume') +print('service_image=pinned') +PY + +case "$CHECKOUT" in + disabled) + [[ "$(code "$API_ORIGIN/api/checkout/v2/payment-accounts")" == "404" ]] || fail "checkout should be disabled" + ;; + enabled) + headers="$(mktemp -t paygate-v2-cors-XXXXXX)" + checkout_code="$(curl -sS -D "$headers" -o /tmp/paygate-v2-preflight-body -w '%{http_code}' --max-time 10 -H "Origin: $ALLOWED_ORIGIN" "$API_ORIGIN/api/checkout/v2/payment-accounts")" + [[ "$checkout_code" == "200" ]] || fail "allowed-origin checkout returned HTTP $checkout_code" + grep -Fqi "Access-Control-Allow-Origin: $ALLOWED_ORIGIN" "$headers" || fail "checkout CORS allow-origin mismatch" + rm -f "$headers" + [[ "$(code -H "Origin: https://denied.invalid" "$API_ORIGIN/api/checkout/v2/payment-accounts")" == "404" ]] || fail "foreign checkout origin was not denied" + ;; + *) fail "usage: $0 [disabled|enabled] [allowed-origin]" ;; +esac + +latest_sidecar="$(find "$BACKUP_DIR" -maxdepth 1 -type f -name '*.zip.sha256' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)" +[[ -n "$latest_sidecar" && -f "$latest_sidecar" ]] || fail "no backup checksum sidecar found" +( + cd "$BACKUP_DIR" + sha256sum -c "$(basename "$latest_sidecar")" >/dev/null +) +archive="${latest_sidecar%.sha256}" +unzip -t "$archive" >/dev/null +work="$(mktemp -d -t paygate-v2-preflight-XXXXXX)" +trap 'rm -rf "$work" /tmp/paygate-v2-preflight-body' EXIT +unzip -q "$archive" -d "$work" +for db in data.db auxiliary.db; do + [[ -f "$work/$db" ]] || fail "$db missing from latest backup" + [[ "$(sqlite3 "$work/$db" 'PRAGMA integrity_check;')" == "ok" ]] || fail "$db integrity check failed" +done + +echo "backup_restore_integrity=ok" +echo "checkout_state=$CHECKOUT" +echo "preflight=passed" diff --git a/scripts/v2-production-copy-acceptance.sh b/scripts/v2-production-copy-acceptance.sh new file mode 100755 index 0000000..81ccbfb --- /dev/null +++ b/scripts/v2-production-copy-acceptance.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "usage: $0 --source-copy /path/to/pb_data --binary /path/to/paygate" >&2 + exit 64 +} + +SOURCE="" +BINARY="" +while [[ $# -gt 0 ]]; do + case "$1" in + --source-copy) SOURCE="${2:-}"; shift 2 ;; + --binary) BINARY="${2:-}"; shift 2 ;; + *) usage ;; + esac +done +[[ -n "$SOURCE" && -n "$BINARY" ]] || usage +[[ -d "$SOURCE" ]] || { echo "source copy not found: $SOURCE" >&2; exit 66; } +[[ -x "$BINARY" ]] || { echo "binary is not executable: $BINARY" >&2; exit 66; } + +SOURCE="$(realpath "$SOURCE")" +case "$SOURCE" in + /app/pb_data|/app/pb_data/*) echo "refusing live production data path" >&2; exit 70 ;; +esac +[[ -f "$SOURCE/data.db" ]] || { echo "data.db missing from source copy" >&2; exit 65; } +[[ -f "$SOURCE/.paygate-acceptance-copy" ]] || { echo "refusing unmarked data directory; create .paygate-acceptance-copy only in an offline/restored copy" >&2; exit 70; } + +WORK="$(mktemp -d -t paygate-v2-acceptance-XXXXXX)" +trap 'rm -rf "$WORK"' EXIT +COPY="$WORK/pb_data" +cp -a "$SOURCE" "$COPY" +chmod -R u+rwX "$COPY" +DB="$COPY/data.db" + +integrity() { + local result + result="$(sqlite3 "$DB" 'PRAGMA integrity_check;')" + [[ "$result" == "ok" ]] || { echo "integrity_check failed: $result" >&2; exit 1; } +} + +query_or_zero() { + local sql="$1" + sqlite3 "$DB" "$sql" 2>/dev/null || echo 0 +} + +snapshot() { + local prefix="$1" + { + echo "payments=$(query_or_zero 'SELECT count(*) FROM payments;')" + echo "sms_events=$(query_or_zero 'SELECT count(*) FROM sms_events;')" + echo "email_events=$(query_or_zero 'SELECT count(*) FROM email_events;')" + echo "notification_events=$(query_or_zero 'SELECT count(*) FROM notification_events;')" + echo "refunds=$(query_or_zero 'SELECT count(*) FROM refunds;')" + echo "reviews=$(query_or_zero 'SELECT count(*) FROM review_cases;')" + echo "relay_devices=$(query_or_zero 'SELECT count(*) FROM relay_devices;')" + echo "relay_events=$(query_or_zero 'SELECT count(*) FROM relay_events;')" + echo "webhook_deliveries=$(query_or_zero 'SELECT count(*) FROM webhook_deliveries;')" + echo "duplicate_rrn=$(query_or_zero "SELECT count(*) FROM (SELECT rrn FROM payments WHERE trim(coalesce(rrn,'')) <> '' GROUP BY rrn HAVING count(*) > 1);")" + echo "duplicate_evidence_reference=$(query_or_zero "SELECT count(*) FROM (SELECT evidence_reference FROM payments WHERE trim(coalesce(evidence_reference,'')) <> '' GROUP BY evidence_reference HAVING count(*) > 1);")" + echo "duplicate_idempotency=$(query_or_zero "SELECT count(*) FROM (SELECT idempotency_key FROM payments WHERE trim(coalesce(idempotency_key,'')) <> '' GROUP BY idempotency_key HAVING count(*) > 1);")" + } > "$WORK/$prefix.txt" +} + +integrity +snapshot before + +"$BINARY" --dir="$COPY" migrate up >/dev/null + +integrity +snapshot after + +for key in payments sms_events email_events notification_events refunds reviews relay_devices relay_events webhook_deliveries duplicate_rrn duplicate_evidence_reference duplicate_idempotency; do + before="$(grep "^${key}=" "$WORK/before.txt" | cut -d= -f2-)" + after="$(grep "^${key}=" "$WORK/after.txt" | cut -d= -f2-)" + if [[ "$before" != "$after" ]]; then + echo "invariant changed during schema migration: $key $before -> $after" >&2 + exit 1 + fi +done + +# v2 schema assertions. +shadow_status="$(sqlite3 "$DB" "SELECT count(*) FROM _collections c, json_each(c.fields) f WHERE c.name='relay_events' AND json_extract(f.value,'$.name')='processing_status' AND EXISTS (SELECT 1 FROM json_each(json_extract(f.value,'$.values')) v WHERE v.value='shadow_observed');")" +[[ "$shadow_status" == "1" ]] || { echo "relay_events.processing_status is missing shadow_observed" >&2; exit 1; } +operator_route_migration="$(query_or_zero "SELECT count(*) FROM _migrations WHERE file LIKE '20260828030000%';")" +[[ "$operator_route_migration" == "1" ]] || { echo "Google Messages shadow migration history is missing" >&2; exit 1; } + +printf 'production-copy acceptance passed\n' +printf 'workspace was isolated and has been removed automatically\n' +cat "$WORK/after.txt" diff --git a/web/index.html b/web/index.html index 9f828a5..58db998 100644 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,7 @@ + PayGate diff --git a/web/src/App.tsx b/web/src/App.tsx index df7d121..7baf3f0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,34 +1,55 @@ import { useEffect, useMemo, useState } from "react"; -import { pb } from "./pb"; +import { auth, refreshAuth as refreshOperatorAuth } from "./api"; import type { Page } from "./types"; import { Dashboard } from "./pages/Dashboard"; +import { Health } from "./pages/Health"; import { Login } from "./pages/Login"; import { Payments } from "./pages/Payments"; +import { More } from "./pages/More"; import { AuditEvents, EmailEvents, SMSEvents, WebhookDeliveries } from "./pages/Records"; import { AlertsPage, ReconciliationPage, RefundsPage, ReviewsPage } from "./pages/Operations"; import { Settings } from "./pages/Settings"; import { RazorpayTestPage } from "./pages/RazorpayTest"; -const pages: Page[] = ["dashboard", "payments", "reviews", "reconciliation", "sms", "email", "alerts", "refunds", "webhooks", "audit", "razorpay_test", "settings"]; +const primaryPages: Page[] = ["dashboard", "payments", "reviews", "health", "more"]; +const advancedPages: Page[] = ["reconciliation", "refunds", "sms", "email", "razorpay_test", "alerts", "webhooks", "audit", "settings"]; +const pages = [...primaryPages, ...advancedPages]; + +const pageMeta: Record = { + dashboard: { label: "Overview", eyebrow: "Today", description: "What needs your attention right now." }, + payments: { label: "Payments", eyebrow: "Money flow", description: "Find, create and manage every payment." }, + reviews: { label: "Action", eyebrow: "Needs a person", description: "Only the cases PayGate cannot decide safely." }, + health: { label: "Health", eyebrow: "System status", description: "The few things that must stay healthy for PayGate to work." }, + more: { label: "More", eyebrow: "Advanced", description: "Investigation, recovery and low-frequency operator tools." }, + reconciliation: { label: "Reconciliation", eyebrow: "Advanced", description: "Compare bank statements without changing payment truth automatically." }, + refunds: { label: "Refunds", eyebrow: "Advanced", description: "Record and audit manual refund workflows." }, + sms: { label: "SMS evidence", eyebrow: "Advanced", description: "Raw operational SMS records." }, + email: { label: "Email evidence", eyebrow: "Advanced", description: "Raw operational bank-email records." }, + razorpay_test: { label: "Razorpay test", eyebrow: "Advanced", description: "Sandbox payment diagnostics." }, + alerts: { label: "Alerts", eyebrow: "Advanced", description: "Operational alert history." }, + webhooks: { label: "Webhooks", eyebrow: "Advanced", description: "Delivery-level diagnostics." }, + audit: { label: "Audit trail", eyebrow: "Advanced", description: "Immutable operator and system actions." }, + settings: { label: "Settings", eyebrow: "Advanced", description: "Low-frequency infrastructure controls." }, +}; function pageFromHash(): Page { - const value = window.location.hash.replace(/^#\/?/, "") as Page; + const value = window.location.hash.replace(/^#\/?/, "").split("?")[0] as Page; return pages.includes(value) ? value : "dashboard"; } export function App() { - const [loggedIn, setLoggedIn] = useState(pb.authStore.isValid); + const [loggedIn, setLoggedIn] = useState(auth.isValid); const [page, setPage] = useState(pageFromHash()); const [notice, setNotice] = useState(""); - useEffect(() => pb.authStore.onChange(() => setLoggedIn(pb.authStore.isValid)), []); + useEffect(() => auth.subscribe(() => setLoggedIn(auth.isValid)), []); useEffect(() => { - if (!pb.authStore.token) return; - const refreshAuth = async () => { - try { await pb.collection("users").authRefresh(); } catch { pb.authStore.clear(); } + if (!auth.token) return; + const refreshSession = async () => { + try { await refreshOperatorAuth(); } catch { auth.clear(); } }; - void refreshAuth(); - const timer = window.setInterval(() => void refreshAuth(), 10 * 60_000); + void refreshSession(); + const timer = window.setInterval(() => void refreshSession(), 10 * 60_000); return () => window.clearInterval(timer); }, [loggedIn]); useEffect(() => { @@ -38,51 +59,58 @@ export function App() { }, []); useEffect(() => { if (!notice) return; - const timer = window.setTimeout(() => setNotice(""), 5000); + const timer = window.setTimeout(() => setNotice(""), 4500); return () => window.clearTimeout(timer); }, [notice]); - const title = useMemo(() => label(page), [page]); + const meta = useMemo(() => pageMeta[page], [page]); if (!loggedIn) return ; + const primaryActive: Page = advancedPages.includes(page) ? "more" : page; function navigate(next: Page) { window.location.hash = `/${next}`; setPage(next); } - return
-