Skip to content

feat: batch operations, plan templates, notification preferences, payment fallback chains#819

Open
distributed-nerd wants to merge 6 commits into
Smartdevs17:mainfrom
distributed-nerd:feat/batch-templates-notifications-payment-methods
Open

feat: batch operations, plan templates, notification preferences, payment fallback chains#819
distributed-nerd wants to merge 6 commits into
Smartdevs17:mainfrom
distributed-nerd:feat/batch-templates-notifications-payment-methods

Conversation

@distributed-nerd

Copy link
Copy Markdown
Contributor

Closes #720
Closes #721
Closes #722
Closes #723

Four merchant-facing capabilities, one commit each so they can be reviewed independently.


#720 — Batch subscription operations

Contract (contracts/batch)

  • Update and Cancel operation types, with per-item cancel reason codes
  • A Processing state plus start/finish ledger timestamps, so status tracking covers pending → processing → completed | partial | failed
  • Per-operation-type BatchConfig (max items, default atomicity, rollback eligibility, gas rate) behind an admin-only setter
  • rollback_batch, which restores the pre-execution snapshot captured during execute — creates are removed, prior state is restored byte for byte, and a subscription touched twice in one batch restores to before the whole batch
  • BatchAnalytics maintained globally and per operation type, with a basis-point success rate and execution timing; a rollback discounts its own successful items
  • Per-item OperationResult codes, and the batch result persisted for later reads

Client (app/)

  • The four near-identical execute paths collapsed into one runner, so atomicity, timing, idempotency and status handling are shared rather than duplicated four ways
  • BatchOperationConfig per operation type, applied to chunking, atomicity defaults, retry budgets and size validation
  • computeBatchAnalytics — success rates, p95/average duration, per-item timing, throughput, partitioned by operation type
  • rollbackBatch driven by a caller-supplied RollbackHandler; a partial reversal leaves the batch partial rather than claiming a clean undo
  • Applied (operation, subscription) pairs are tracked so a re-submitted batch cannot double-charge, and successes an atomic rollback discarded are forgotten so a corrected re-run applies them

The unused contracts/batch/src/batch.rs was removed — it duplicated types now in lib.rs and did not compile.

#722 — Plan templates

Contract (contracts/subscription) — new plan_templates module: template registry, per-owner index, version chains, a shared library and per-template analytics. Storage lives behind a single StorageKey::PlanTemplate(TemplateKey) variant rather than six flat ones.

Backend (backend/services/billing)planTemplateService with the same semantics over a repository interface. Quoting reuses TieredPricingCalculator, so client and server price a ladder identically.

Clientsrc/types/planTemplate.ts as shared vocabulary, planTemplateStore (library, filters, quoting, versioning, sharing, analytics, three starter templates), subscriptionStore.addFromTemplate, and a PlanTemplatesScreen registered in the root and subscription navigators.

Notable behaviours:

  • Graduated pricing ladders, validated ascending with an unbounded rung only at the end
  • Per-instantiation overrides that never mutate the template
  • Publishing a version retires the previous one, which stays readable — so plans already created from it remain explainable — and leaves the shared library
  • addFromTemplate records a conversion only when the subscription actually landed, so a failed create can't inflate the rate

#721 — Notification preferences

Shared types — four channels (email/push/SMS/in-app) and nine notification types, each with a category, priority, default channels and a required flag.

BackendnotificationCenterService: preferences per type and per channel with fallback ordering, multi-channel delivery over pluggable transports (fan out, or stop at the first channel that accepts), quiet-hours deferral with a queue flush, per-(type, channel) templates with versioning and {{variable}} rendering, and history with read/click status feeding delivery, open and click rates.

Client — the store gains the per-type channel matrix, templates, history and analytics while keeping the existing category/digest/quiet-hours/priority settings working; notificationService.deliverNotification resolves preferences, renders the channel's template and dispatches through registered transports; the settings screen gains the channel matrix, per-type mute, an engagement panel and recent activity with read state.

Notable behaviours:

  • A required type cannot be muted and cannot lose its last channel, so a subscriber stays reachable about money and account security
  • A muted or unroutable notification is recorded as suppressed, never dropped — analytics can tell "not sent" from "sent and ignored"
  • Critical notifications go out through quiet hours; everything else defers to the end of the window
  • A click implies a read, so engagement is never reported as "clicked but never opened"

#723 — Payment method fallback chains

A fallback chain is an explicit, ordered route — "try the card, then the USDC wallet, then the treasury" — replacing implicit priority ordering. A charge walks it until one method succeeds, so a single expired card no longer means a failed renewal.

  • src/services/paymentMethodService.ts — chain construction from the existing priority ordering, validation (length, duplicates, unusable members), resolution, and processPaymentWithChain reporting which position succeeded
  • src/store/walletStore.ts — chains and shares as first-class persisted state, cleared on disconnect
  • backend/services/billing/paymentMethodRegistry.ts — the same model server-side over a pluggable charge processor, with method CRUD, verification and per-merchant analytics
  • app/paymentStore gains chains, alerts and analytics; PaymentMethodsScreen gains a chain builder with reordering and inline validation, a severity-coded expiry alert panel, and an analytics panel

Notable behaviours:

  • A hard decline (expired, inactive, unverified) halts the chain when configured that way, since falling through would only repeat a configuration problem
  • Expiry alerts are graded expired/critical/warning and flag any method still sitting in an active chain — its expiry breaks a charge rather than merely retiring an unused method
  • Removing a method drops it from every chain, so a chain never points at something uncharageable
  • fallbackRate reports the share of successful charges that only landed because the fallback existed — the number that says whether the chain is doing real work
  • Sharing supports viewer / charger roles with spend limits; an elapsed share behaves exactly like a revoked one, so no cleanup job is needed

Documentation

New: docs/BATCH_OPERATIONS.md, docs/PLAN_TEMPLATES.md, docs/NOTIFICATIONS.md, docs/PAYMENT_METHODS.md.

contracts/batch/BATCHING_API.md was rewritten — it documented a hook-based API (useBatchTransactions, addTransaction) that was never implemented.

Verification

Check Result
tsc --noEmit Clean — only the 3 errors already present on main
src/ tests 793 passed; the 1 failing suite (navigationAnalytics.test.ts, a Babel parse error) also fails on main
Backend tests 734 passed; the same 26 suites that fail on main still fail, none newly
Lint / Prettier Byte-identical to baseline (127 problems, 11 errors — all pre-existing)

197 new tests, all passing: 24 contract, 31 batch service, 16 batch store, 30 plan template service, 26 plan template store, 36 notification center, 31 notification store, 34 payment registry, 22 payment store, 5 contract unit.

Two caveats worth reviewer attention

1. The Rust contracts could not be compiled or tested — and this is pre-existing, not introduced here.

The workspace does not build on main either. soroban-sdk ≤ 21.5 pins ethnum 1.5.0, which fails to compile on a modern rustc; ≥ 21.6 caps contract enum spec cases at 50, and CoreError (51 variants) and StorageKey (52) already exceed it, so subtrackr-types fails its #[contracterror] / #[contracttype] macros. No version combination compiles. Since CI uses dtolnay/rust-toolchain@master, npm run contracts:test is very likely already red.

The Rust here was verified by review, and every changed file parses and is rustfmt --check clean — but it has not been type-checked or executed. The enum-size problem is worth fixing separately before relying on the contract changes. This PR adds one variant to StorageKey (namespaced, rather than the six a flat layout would have needed) to keep the pressure minimal.

2. app/ is excluded from tsconfig.json, .eslintignore and jest's testPathIgnorePatterns.

The 57 batch and payment-method tests under app/ pass, but need an override to run:

npx jest --testPathIgnorePatterns "/node_modules/" --testPathPattern "app/(services|stores)/__tests__"

I left the config alone because enabling app/ would turn CI red on roughly 21 pre-existing failing suites there — but it does mean a meaningful slice of this branch isn't covered by npm test. Separately, .eslintignore's services/ entry also matches src/services/, so that whole directory is silently unlinted.

Batch subscription management was limited to create/charge with a single
global configuration, no timing or success-rate reporting, and no way to
undo a batch that had already committed.

Contract (contracts/batch):
- add Update and Cancel operation types with per-item cancel reason codes
- add a Processing state and record start/finish ledger timestamps so
  status tracking covers pending -> processing -> completed/partial/failed
- add per-operation-type BatchConfig (max items, default atomicity,
  rollback eligibility, gas rate) with an admin-only setter
- add rollback_batch, which restores the pre-execution snapshot captured
  during execute; creates are removed, prior state is restored byte for
  byte, and a subscription touched twice restores to before the batch
- add BatchAnalytics maintained globally and per operation type, with a
  basis-point success rate and execution timing; a rollback discounts its
  successful items
- return per-item OperationResult codes and persist the batch result
- delete the unused batch.rs module, whose types are now in lib.rs

Client (app/):
- collapse the four near-identical execute paths into one runner so
  atomicity, timing, idempotency and status handling are shared
- add BatchOperationConfig per operation type, applied to chunking,
  atomicity defaults, retry budgets and size validation
- add computeBatchAnalytics for success rates, p95/average duration,
  per-item timing and throughput, partitioned by operation type
- add rollbackBatch driven by a caller-supplied RollbackHandler; a
  partial reversal leaves the batch partial rather than claiming success
- track applied (operation, subscription) pairs so a re-submitted batch
  cannot double-charge, and forget successes an atomic rollback discarded
- rename the in-flight state from running to processing to match the
  contract, and surface configuration, analytics and rollback in the UI

Docs: add docs/BATCH_OPERATIONS.md and rewrite the contract's
BATCHING_API.md, which documented an API that was never implemented.

Tests: 24 new contract tests, 31 service tests, 16 store tests.
…aring

Plans were built from scratch every time. Plan templates are reusable
blueprints — base price, billing cycle, an optional graduated pricing
ladder and a feature list — that a merchant instantiates a plan from.

Contract (contracts/subscription):
- add a plan_templates module: template registry, per-owner index,
  version chains, a shared library and per-template analytics
- graduated pricing ladders, validated ascending with an unbounded rung
  only at the end, and quoted per unit
- per-instantiation overrides that resolve to plan parameters without
  ever mutating the template
- publishing a version retires the previous one, which stays readable so
  plans already created from it remain explainable, and withdraws it from
  the shared library
- contract entry points, including create_plan_from_template, which
  forwards to create_plan so template-made plans need no special casing
- namespace the feature's storage behind one StorageKey::PlanTemplate
  variant rather than six flat ones

Backend (backend/services/billing):
- planTemplateService with the same library, versioning, sharing,
  customization and analytics semantics, over a repository interface
- quoting reuses TieredPricingCalculator, so client and server price a
  ladder identically

Client:
- src/types/planTemplate.ts as the shared vocabulary
- planTemplateStore with the library, filters, quoting, versioning,
  sharing and analytics, seeded with three starter templates
- subscriptionStore.addFromTemplate, which records a conversion only when
  the subscription actually landed
- PlanTemplatesScreen to browse, quote, customize and instantiate,
  registered in the root and subscription navigators

Analytics track views, plans created, subscriptions started and revenue,
deriving adoption and conversion rates capped at 100%.

Docs: add docs/PLAN_TEMPLATES.md.

Tests: 30 backend tests, 26 store tests, 5 contract unit tests.
Notifications went out without preference management: a subscriber
either had push permission or did not, and nothing recorded whether a
notification was delivered, suppressed or ever read.

Shared types (src/types/notification.ts):
- four channels and nine notification types, each with a category,
  priority, default channels and a required flag

Backend (backend/services/notification):
- notificationCenterService holding preferences per type and per channel,
  with fallback ordering
- multi-channel delivery over pluggable transports, either fanning out or
  stopping at the first channel that accepts
- required types cannot be muted and cannot lose their last channel, so a
  subscriber stays reachable about money and account security
- a muted or unroutable notification is recorded as suppressed rather than
  dropped, so analytics can tell "not sent" from "sent and ignored"
- quiet-hours deferral for non-critical notifications, with a queue flush
  once the window ends; critical notifications go out regardless
- per-(type, channel) templates with versioning and {{variable}} rendering
  that reports unfilled variables instead of printing raw placeholders
- history with read and click status, and analytics deriving delivery,
  open and click rates per channel and per type, plus time-to-read and the
  channel the subscriber opens most

Client:
- notificationPreferencesStore extended with the per-type channel matrix,
  templates, history and analytics, keeping the existing category, digest,
  quiet-hours and priority settings working
- notificationService gains deliverNotification, which resolves
  preferences, renders the channel's template and dispatches through
  registered transports; push uses Expo, other channels are host-supplied
- NotificationPreferencesScreen gains the channel matrix, per-type mute,
  an engagement panel and recent activity with read state

Docs: add docs/NOTIFICATIONS.md.

Tests: 36 backend tests, 31 store and delivery tests.
Payment methods were managed one at a time, ordered only by an implicit
priority label, with no way to say "try the card, then the USDC wallet,
then the treasury". A fallback chain is that explicit, ordered route: a
charge walks it until one method succeeds, so a single expired card no
longer means a failed renewal.

Types (src/types/wallet.ts):
- FallbackChain, PaymentMethodShare, expiry alerts and analytics shapes

Client service (src/services/paymentMethodService.ts):
- chain construction from the existing priority ordering, validation
  (length, duplicates, unusable members) and resolution
- processPaymentWithChain, which reports which position succeeded and
  halts on a hard decline when the chain is configured that way
- expiry alerts graded expired/critical/warning, flagging any method still
  sitting in an active chain since its expiry breaks a charge rather than
  merely retiring an unused method
- analytics: per-method success rates, ranked failure reasons, and a
  fallback rate that says whether the chain is doing real work
- sharing with viewer and charger roles, spend limits and expiring grants

Client store (src/store/walletStore.ts):
- chains and shares as first-class persisted state, with charge routing,
  expiry alerts and analytics selectors; both are cleared on disconnect

Backend (backend/services/billing/paymentMethodRegistry.ts):
- the same model server-side over a pluggable charge processor, including
  method CRUD, verification, chain-scoped charging and per-merchant
  analytics; removing a method drops it from every chain so a chain never
  points at something uncharageable

App layer:
- paymentStore gains chains, expiry alerts and analytics over its local
  method model
- PaymentMethodsScreen gains a chain builder with reordering and inline
  validation, a severity-coded expiry alert panel, and an analytics panel

Docs: add docs/PAYMENT_METHODS.md.

Tests: 34 backend tests, 22 new app store tests.
@drips-wave

drips-wave Bot commented Jul 26, 2026

Copy link
Copy Markdown

@distributed-nerd Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

The Lint & Format, Type Check and Tests jobs were already failing on
main; this branch inherited them. None of the causes are in the feature
work, so they are fixed here in one pass.

Type Check:
- SettingsScreen and NotFoundScreen have no default export, so the
  lazyScreen() imports never satisfied Promise<{default: ComponentType}>.
  Map the named export onto that shape. These two routes would have
  failed to resolve at runtime.
- calculatePauseAnalytics is declared as returning PauseAnalytics but
  only set state. Return the value it already computed.

Lint & Format:
- navigationAnalytics.test.ts contained JSX in a .ts file, so prettier
  aborted with a syntax error and jest could not parse it. The JSX was an
  unused test wrapper; removing it fixes the parse and the file stays .ts.
- Drop unused imports and bindings flagged by no-unused-vars across
  NavigationErrorBoundary, navigation/analytics, MerchantOnboardingScreen,
  PauseResumeScreen and TrialDetailsScreen. setReason was never called, so
  reason was already always '' — behaviour is unchanged.
- Run prettier over three developer-portal components and two dr scripts
  that were unformatted.

Tests:
- navigationAnalytics.test.ts now parses and runs, adding 9 tests that
  had never executed.

npm run lint, npx tsc --noEmit, npm run format:check and npm test all
exit 0; 73/73 suites and 802/802 tests pass.
The Tests job failed on imageCache's "evicts the LRU entry when at
capacity" while passing locally — a genuine flake that can hit any run,
including on main.

_evictLRU picked the entry with the smallest lastAccessedAt using a
strict <, but Date.now() only has millisecond resolution. When several
registrations land in the same tick their timestamps tie, the first
entry iterated stays "oldest", and the cache evicts the most recently
used entry instead of the least. The test papered over this with 1ms
setTimeout delays, which do not guarantee the clock advances at all —
so on a fast runner it failed exactly as CI reported.

The index is now kept in recency order: register re-inserts an entry on
access, hydrate restores the persisted order, and eviction drops the
first key. That is deterministic regardless of how many accesses share a
millisecond, and it fixes the underlying cache bug, not just the test.

The test's artificial delays are removed, so it now asserts the stronger
property: eviction order holds even when every access is in the same
millisecond. Against the old implementation that revised test fails with
the exact CI error (Expected: false, Received: true); against the new one
it passes.

lint, tsc --noEmit, format:check and npm test all exit 0; 73/73 suites
and 802/802 tests pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant