Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2abaa5e
Enforce admin-only authority and harden SQLite writes
Phloraxx Sep 6, 2026
8ac8623
Harden notification evidence matching and relay health
Phloraxx Sep 6, 2026
8f805fa
Add isolated restore drill and restrict webhook egress
Phloraxx Sep 6, 2026
5e0f9d0
fix: block untrusted notification confirmations
Phloraxx Sep 6, 2026
6f97674
fix: bound relay body retention and pairing epochs
Phloraxx Sep 6, 2026
dd45d5e
fix: drain expired relay bodies in bounded batches
Phloraxx Sep 6, 2026
b6dc31f
fix: bind relay mutations and retention safety
Phloraxx Sep 6, 2026
ab8f232
fix: release sqlite writer during admin login
Phloraxx Sep 6, 2026
a220ce4
fix: reject stale relay replays
Phloraxx Sep 6, 2026
cca8d6b
fix: verify password changes outside sqlite writer
Phloraxx Sep 6, 2026
82abd22
api: map sqlite contention to retryable responses
Phloraxx Sep 6, 2026
1b0ed4c
relay: classify heartbeat contention as retryable
Phloraxx Sep 6, 2026
31c76f3
fix: preserve admin session on busy logout
Phloraxx Sep 6, 2026
ed5bec6
refactor: match app payments by exact amount
Phloraxx Sep 7, 2026
b8aaf5b
fix: bind relay mutations to enrollment epoch
Phloraxx Sep 7, 2026
40d6b14
style: format combined relay changes
Phloraxx Sep 7, 2026
9b56755
fix: bind repaired relays to enrollment epoch
Phloraxx Sep 7, 2026
3f5ecc3
fix: make relay enrollment epochs monotonic
Phloraxx Sep 7, 2026
18a565d
fix: honor reservation release time when matching
Phloraxx Sep 7, 2026
5387e86
fix: migrate overlapping amount reservations safely
Phloraxx Sep 7, 2026
7257e90
fix: bind Paytm evidence to Paytm profile
Phloraxx Sep 7, 2026
ad168bc
fix: accept transitional restore schemas
Phloraxx Sep 8, 2026
f8e62de
test: pin migrated idempotency clock
Phloraxx Sep 8, 2026
4787d94
Harden trusted payment notification parsing
Phloraxx Sep 8, 2026
d896685
Preserve v4 rollback compatibility
Phloraxx Sep 8, 2026
c9b29c0
chore: simplify notification parser cleanup
Phloraxx Sep 9, 2026
f4b1784
fix: reject over-precision payment amounts
Phloraxx Sep 9, 2026
73f5ecc
test: reject over-precision payment amounts
Phloraxx Sep 9, 2026
28d44c3
fix: reject malformed payment amount tokens
Phloraxx Sep 9, 2026
ec83a6f
feat: redesign payment operations overview
Phloraxx Sep 10, 2026
9321744
fix: isolate PayGate mark gradients
Phloraxx Sep 10, 2026
71f61ce
fix: harden payment overview refresh
Phloraxx Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ PAYGATE_V4_WEBHOOK_SECRET=replace-with-a-long-random-webhook-secret
# Online SQLite backup schedule.
PAYGATE_V4_BACKUP_HOUR_UTC=3
PAYGATE_V4_BACKUP_RETENTION=30
# Raw relay notification body retention; normalized evidence is retained.
PAYGATE_V4_RAW_EVENT_RETENTION=168h

# paygate-v4-migrate only; not required by the normal server.
PAYGATE_V4_ACTIVE_PROFILE=kotak
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

# PayGate

PayGate creates a payment instruction with a unique exact payable amount, watches supported incoming-payment notifications through one trusted Android phone, and marks the matching payment only when the server can do so unambiguously.
PayGate creates a payment instruction with a unique exact payable amount, watches supported incoming-payment notifications through one or more independently revocable Android relay phones, and changes payment state only when the server has an origin-bound confirmation it can trust.

The payer sends money directly to the configured UPI account. PayGate does not custody, route, or settle funds; notification matching is an evidence mechanism rather than a bank/acquirer settlement guarantee.

Expand Down Expand Up @@ -54,6 +54,7 @@ v4.0 currently supports:

- **Paytm for Business** payment notifications;
- **Kotak** credit notifications delivered by Google Messages on the PayGate phone.
Paytm package-bound notifications are currently the only automatic confirmation source. Kotak/Google Messages and package-agnostic notifications remain signed evidence for web-admin confirmation until an independent sender/provider proof is available.

The merchant does not select Paytm or Kotak. PayGate snapshots the active profile and destination when the payment is created, so later profile changes cannot alter an existing payment.

Expand Down Expand Up @@ -108,7 +109,7 @@ The web UI is embedded in the v4 server image. The Android app remains a separat

The PayGate phone uses `NotificationListenerService`, a durable local queue and a P-256 ECDSA key stored in Android Keystore.

Android performs only cheap source allowlisting and notification capture. The server owns source-specific parsing, incoming-credit semantics, profile inference, matching, deduplication and payment mutation.
Android performs only cheap source-agnostic notification capture. The server owns source-specific parsing, incoming-credit semantics, profile inference, source trust, matching, deduplication and payment mutation.

The foreground relay is intentionally independent of operator login and is designed to survive screen lock, Doze, process recreation, temporary network loss and normal Battery Saver when the app is exempt from battery optimization.
## Persistence and recovery
Expand Down Expand Up @@ -150,7 +151,7 @@ CI validates the v4 frontend, all retained Go packages, static analysis and the
- plaintext merchant/webhook secrets are never persisted when a verifier/hash is sufficient;
- Android private signing keys remain non-exportable in Android Keystore;
- payer identity and raw notification detail stay out of unauthenticated/public payment views;
- a false-positive confirmation is considered worse than a delayed/manual outcome, so matching fails closed;
- a false-positive confirmation is considered worse than a delayed/manual outcome, so only origin-bound evidence may transition payment state; generic notifications and Google Messages/SMS remain evidence for operator confirmation;
- never run a second PayGate process against the live SQLite volume.

## Documentation
Expand Down
53 changes: 51 additions & 2 deletions cmd/paygate-v4/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"

v4runtime "github.com/Phloraxx/payment-api/internal/v4/runtime"
"github.com/Phloraxx/payment-api/internal/v4/storage"
)

func main() {
Expand All @@ -23,8 +27,13 @@ func main() {
}

func run() error {
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
return healthcheck()
if len(os.Args) > 1 {
switch os.Args[1] {
case "healthcheck":
return healthcheck()
case "restore-drill":
return restoreDrill()
}
}
cfg, err := configFromEnv()
if err != nil {
Expand Down Expand Up @@ -88,6 +97,37 @@ func healthcheck() error {
}
return nil
}
func restoreDrill() error {
flags := flag.NewFlagSet("restore-drill", flag.ContinueOnError)
flags.SetOutput(io.Discard)
backup := flags.String("backup", "", "completed standalone SQLite backup")
liveDB := flags.String("live-db", "", "live database path to protect")
expectedSHA256 := flags.String("sha256", "", "expected backup SHA-256")
if err := flags.Parse(os.Args[2:]); err != nil {
return fmt.Errorf("parse restore-drill arguments: %w", err)
}
if flags.NArg() != 0 {
return errors.New("restore-drill does not accept positional arguments")
}
if strings.TrimSpace(*backup) == "" {
return errors.New("restore-drill requires --backup")
}
protectedPath := strings.TrimSpace(*liveDB)
if protectedPath == "" {
dataDir := strings.TrimSpace(os.Getenv("PAYGATE_V4_DATA_DIR"))
if dataDir == "" {
return errors.New("restore-drill requires --live-db or PAYGATE_V4_DATA_DIR")
}
protectedPath = filepath.Join(dataDir, "paygate.db")
}
report, err := storage.RestoreDrill(context.Background(), *backup, protectedPath, *expectedSHA256)
if err != nil {
return err
}
fmt.Printf("restore drill passed: schema=%d payments=%d relay_events=%d webhook_deliveries=%d relay_devices=%d\n",
report.SchemaVersion, report.Payments, report.RelayEvents, report.WebhookDeliveries, report.RelayDevices)
return nil
}

func configFromEnv() (v4runtime.Config, error) {
hour := 3
Expand All @@ -106,6 +146,14 @@ func configFromEnv() (v4runtime.Config, error) {
}
retention = parsed
}
var rawEventRetention time.Duration
if value := strings.TrimSpace(os.Getenv("PAYGATE_V4_RAW_EVENT_RETENTION")); value != "" {
parsed, err := time.ParseDuration(value)
if err != nil {
return v4runtime.Config{}, fmt.Errorf("invalid PAYGATE_V4_RAW_EVENT_RETENTION: %w", err)
}
rawEventRetention = parsed
}
origins := splitCSV(os.Getenv("PAYGATE_V4_ALLOWED_ORIGINS"))
return v4runtime.Config{
DataDir: os.Getenv("PAYGATE_V4_DATA_DIR"),
Expand All @@ -119,6 +167,7 @@ func configFromEnv() (v4runtime.Config, error) {
BackupDir: os.Getenv("PAYGATE_V4_BACKUP_DIR"),
BackupHourUTC: hour,
BackupRetention: retention,
RawEventRetention: rawEventRetention,
}, nil
}

Expand Down
15 changes: 14 additions & 1 deletion cmd/paygate-v4/main_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package main

import "testing"
import (
"testing"
"time"
)

func TestConfigFromEnvUsesExplicitV4BootstrapValues(t *testing.T) {
t.Setenv("PAYGATE_V4_DATA_DIR", t.TempDir())
Expand All @@ -21,6 +24,16 @@ func TestConfigFromEnvUsesExplicitV4BootstrapValues(t *testing.T) {
t.Fatal("explicit v4 webhook secret missing")
}
}
func TestConfigFromEnvReadsRawEventRetention(t *testing.T) {
t.Setenv("PAYGATE_V4_RAW_EVENT_RETENTION", "48h")
cfg, err := configFromEnv()
if err != nil {
t.Fatal(err)
}
if cfg.RawEventRetention != 48*time.Hour {
t.Fatalf("raw event retention = %s", cfg.RawEventRetention)
}
}

func TestConfigFromEnvIgnoresRemovedLegacyBootstrapAliases(t *testing.T) {
t.Setenv("PAYGATE_V4_DATA_DIR", t.TempDir())
Expand Down
4 changes: 2 additions & 2 deletions docs/v4/00_PRODUCT_VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,11 @@ The frontend never chooses the collection profile and never constructs a destina

## Android responsibility

The phone is a trustworthy sensor, not the payment engine.
The phone is a transport sensor, not the payment engine or a payment-proof authority.

It knows:

- allowed notification packages;
- package identity as evidence, without a payment-app allowlist;
- a cheap generic decimal-money prefilter;
- notification package/key/text/post time;
- durable local queue/retry;
Expand Down
17 changes: 8 additions & 9 deletions docs/v4/01_TARGET_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Owns:
- short-lived QR pairing sessions;
- enrolled Android public key;
- signed request verification;
- one active relay-device policy for v4.0;
- additive relay-device enrollment and independent revocation;
- heartbeat/device health;
- event deduplication.

Expand Down Expand Up @@ -243,7 +243,7 @@ not requested amount.

### Android

- package allowlist;
- capture package identity as evidence; do not maintain an Android payment-package allowlist;
- cheap generic decimal-money prefilter;
- capture notification package/key/text/post time;
- stable local event ID;
Expand Down Expand Up @@ -290,9 +290,8 @@ Oracle/Docker host
local paygate.db + WAL/SHM
completed-backup exporter

Android phone
one PayGate APK
```
Android phones
one or more additive PayGate APK relay clients

Production invariant remains one PayGate process owning the live SQLite database.

Expand All @@ -311,11 +310,11 @@ Production invariant remains one PayGate process owning the live SQLite database

## Future-source extension rule

Adding GPay/Slice later should require only:
Adding GPay/Slice or another source later should require only:

1. allowlist a new Android package if necessary;
2. add server parser + sanitized fixtures;
3. map parser to a collection profile/source;
1. add parser + sanitized fixtures while the source-agnostic transport remains unchanged;
2. map parser output to a collection profile/source;
3. independently authenticate the source before allowing automatic confirmation; otherwise retain evidence for operator confirmation;
4. pass the same observation/matching pipeline.

It must not require changing merchant payment creation or Android/server ownership boundaries.
29 changes: 14 additions & 15 deletions docs/v4/02_NOTIFICATION_INGESTION_AND_PAIRING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ The server decides:

## Universal package intake

Android does not maintain a payment-app package allowlist. A package name is retained as evidence, but package identity alone never authorizes a payment match. This allows BHIM, Google Pay, PhonePe, bank apps and future sources to work without an Android release when their visible notification text satisfies the generic server parser.
Android does not maintain a payment-app package allowlist. A package name is retained as evidence, but package identity and visible text alone never authorize a payment match. This keeps BHIM, Google Pay, PhonePe, bank apps and future sources capturable without an Android release; automatic payment confirmation still requires an independently trusted source.

Specialized server parsers remain useful for sources with stronger known semantics, but they are an optimization and confidence boundary rather than an Android transport restriction.
Specialized server parsers remain useful for sources with stronger origin-bound semantics. Generic and Google Messages/SMS evidence is retained for diagnostics and operator confirmation, not automatic payment confirmation.

## Generic on-device prefilter

Expand Down Expand Up @@ -152,12 +152,13 @@ It extracts best-effort:
- transaction time when text contains one.

Unknown decimal-money Google Messages notifications are ignored/unmatched; they are not treated as Kotak by default.
Even a positively recognized Kotak credit remains evidence-only: Google Messages exposes SMS text, not an authenticated bank/provider assertion, so it is stored as ambiguous when it has a payment candidate and cannot enqueue `payment.paid`.

### Generic incoming-payment notifications

Any other package can produce `android_notification` evidence when the bounded visible text positively expresses an incoming payment and contains a PayGate decimal amount. Google Messages messages that are not recognized as Kotak use the equivalent `android_message` source. Real regression fixtures cover generic wallet/bank wording and BHIM notifications, including dotted UPI IDs.

Generic evidence does **not** inherit whichever collection profile happens to be active when the HTTP request arrives. The server first searches historical amount reservations using the exact payable amount and trusted occurrence time. One qualifying profile is used; multiple qualifying profiles are ambiguous and cannot pay either candidate. With no historical candidate, the current active profile is retained only so unmatched diagnostic evidence can still be recorded.
Generic evidence does **not** inherit whichever collection profile happens to be active when the HTTP request arrives. The server first searches historical amount reservations using the exact payable amount and trusted occurrence time. One qualifying profile is used for evidence attribution; multiple qualifying profiles are ambiguous. Even one qualifying generic candidate is stored as ambiguous/manual evidence and cannot change payment state or enqueue a webhook. With no historical candidate, the current active profile is retained only so unmatched diagnostic evidence can still be recorded.

The normalized observation schema is intentionally not tied to a fixed app package enum, so adding another wallet or bank notification usually requires only parser fixtures unless its wording needs a specialized parser.

Expand Down Expand Up @@ -233,10 +234,10 @@ One real UPI credit may create more than one phone notification. Example: a Kota

PayGate must not try to collapse those notifications on the phone. Each source event remains independently signed and stored. The **payment** is the dedupe anchor:

1. first safe observation -> `matched`; if needed, transition payment to `paid` and enqueue exactly one `payment.paid` webhook;
2. later independent observation for the same historical reservation -> `corroborated`, attach to the same payment and optionally enrich missing payer fields;
3. exact retry of the same relay event ID -> replay prior result without a second observation;
4. reused amount + insufficient timestamp confidence -> `ambiguous`, never assume it corroborates the newest payment.
1. a trusted origin-bound observation may match and enqueue exactly one `payment.paid` webhook;
2. generic/GPay and Google Messages/Kotak observations remain ambiguous evidence for operator confirmation, even when their amount/profile/time candidate is unique;
3. later trusted evidence for the same historical reservation may be `corroborated` without a second payment transition/webhook;
4. exact retry of the same relay event ID replays the prior result; reused amount + insufficient timestamp confidence remains ambiguous.

This makes duplicate handling provider-agnostic and does not require UTR/RRN or fragile notification-text hashes.

Expand All @@ -246,7 +247,7 @@ Android should not upload unrelated personal notifications.

Rules:

- allowlist only required apps;
- capture unrelated package text only when the cheap decimal-money candidate filter passes;
- cheap decimal-money filter before persistence/upload;
- bounded title/text/big-text sizes;
- short local retention;
Expand Down Expand Up @@ -326,19 +327,17 @@ https://pay.mulearnscet.in/device/pair/<single-use-token>
- consumption and device enrollment happen atomically;
- failed/expired/used token cannot be replayed.

## One active relay phone in v4.0
## Additive relay phones

Because the product currently needs one phone, keep the operational model simple:
Relay phones are additive and may remain enabled concurrently:

```text
one active payment relay device
one or more independently revocable payment relay devices
```

Pairing a second phone should require an explicit **Replace device** flow that revokes/disables the old relay only after the new enrollment succeeds.
Pairing a new phone adds its device-key row. It does not disable or replace existing phones. Operators revoke a specific device explicitly when it is lost, retired or no longer trusted.

This avoids two phones delivering duplicate notifications for the same bank/payment stream.

Historical device records may remain for audit.
Historical device records remain for audit, and each device's signed events are deduplicated independently.

## Device signing

Expand Down
24 changes: 14 additions & 10 deletions docs/v4/03_PAYMENT_LIFECYCLE_AND_MATCHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,9 @@ Preference:

## Reuse/collision safety rule

If a payable value has only one historical reservation compatible with the trusted occurrence time, it may match that reservation.
If the payable value has only one historical reservation compatible with the trusted occurrence time, it is a necessary candidate, not sufficient proof.

Only an origin-bound source may automatically confirm that candidate. The current v4 Paytm notification path is bound to the Paytm Business package; generic package text and Google Messages/Kotak SMS have no authenticated app, sender or provider assertion. Those observations are stored as ambiguous Activity for explicit web-admin confirmation or future independent provider corroboration.

If the same value has been reused and the observation time is missing, implausible or too low-confidence to distinguish the reservations:

Expand All @@ -201,37 +203,39 @@ DO NOT AUTO-MATCH

Store the observation as unmatched/ambiguous Activity. The operator can correct the payment directly if necessary.

This is the final defense against extremely delayed SMS/notification delivery.
This is the final defense against extremely delayed SMS/notification delivery and forged visible text.

## Auto-match algorithm

For normalized observation `O`:

```text
1. dedupe signed relay event
2. parse source and validate incoming-credit semantics
3. validate amount > 0 and paise != 00
4. derive occurred_at + confidence/source
5. resolve collection profile P from source semantics or, for generic evidence, historical exact-amount reservations at occurred_at
6. find historical payments on P with exact payable amount
7. restrict candidates to payments whose lifecycle can contain O.occurred_at
8. if exactly one candidate is safe and not yet paid:
8. if the source is origin-bound and exactly one candidate is safe and not yet paid:
mark paid
attach observation as `matched`
copy payer enrichment when present
append payment history
enqueue one payment.paid webhook in same transaction
9. if exactly one safe candidate is already paid and already has a confirming observation:
attach this independent observation as `corroborated`
9. if the source is generic or Google Messages/Kotak, retain the candidate as ambiguous evidence:
do not mutate payment state
do not attach a payment confirmation
do not enqueue a payment.paid webhook
10. if exactly one trusted candidate is already paid and already has a confirming observation:
attach this independent trusted observation as `corroborated`
optionally enrich missing payer fields
do not create another payment transition/history/webhook
10. if zero candidates:
11. if zero candidates:
save unmatched Activity
11. if multiple/uncertain candidates:
12. if multiple/uncertain candidates:
fail closed; save ambiguous Activity
```

The **currently active collection profile is irrelevant to a match when historical evidence identifies the profile**. A Paytm observation can still pay an older Paytm payment after the operator switches new payment creation to Kotak. Generic evidence similarly follows a unique historical reservation profile; if the same amount was simultaneously reserved on multiple profiles, it fails closed as ambiguous rather than guessing.
The **currently active collection profile is irrelevant to a trusted match when historical evidence identifies the profile**. A Paytm observation can still pay an older Paytm payment after the operator switches new payment creation to Kotak. Generic/Kotak evidence can still be attributed to the unique historical reservation for operator review, but never changes payment state automatically.

## Relay amount hint is not trusted

Expand Down
Loading