feat: batch operations, plan templates, notification preferences, payment fallback chains#819
Open
distributed-nerd wants to merge 6 commits into
Conversation
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.
|
@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! 🚀 |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)UpdateandCanceloperation types, with per-item cancel reason codesProcessingstate plus start/finish ledger timestamps, so status tracking coverspending → processing → completed | partial | failedBatchConfig(max items, default atomicity, rollback eligibility, gas rate) behind an admin-only setterrollback_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 batchBatchAnalyticsmaintained globally and per operation type, with a basis-point success rate and execution timing; a rollback discounts its own successful itemsOperationResultcodes, and the batch result persisted for later readsClient (
app/)BatchOperationConfigper operation type, applied to chunking, atomicity defaults, retry budgets and size validationcomputeBatchAnalytics— success rates, p95/average duration, per-item timing, throughput, partitioned by operation typerollbackBatchdriven by a caller-suppliedRollbackHandler; a partial reversal leaves the batchpartialrather than claiming a clean undo(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 themThe unused
contracts/batch/src/batch.rswas removed — it duplicated types now inlib.rsand did not compile.#722 — Plan templates
Contract (
contracts/subscription) — newplan_templatesmodule: template registry, per-owner index, version chains, a shared library and per-template analytics. Storage lives behind a singleStorageKey::PlanTemplate(TemplateKey)variant rather than six flat ones.Backend (
backend/services/billing) —planTemplateServicewith the same semantics over a repository interface. Quoting reusesTieredPricingCalculator, so client and server price a ladder identically.Client —
src/types/planTemplate.tsas shared vocabulary,planTemplateStore(library, filters, quoting, versioning, sharing, analytics, three starter templates),subscriptionStore.addFromTemplate, and aPlanTemplatesScreenregistered in the root and subscription navigators.Notable behaviours:
addFromTemplaterecords 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
requiredflag.Backend —
notificationCenterService: 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.deliverNotificationresolves 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:
suppressed, never dropped — analytics can tell "not sent" from "sent and ignored"#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, andprocessPaymentWithChainreporting which position succeededsrc/store/walletStore.ts— chains and shares as first-class persisted state, cleared on disconnectbackend/services/billing/paymentMethodRegistry.ts— the same model server-side over a pluggable charge processor, with method CRUD, verification and per-merchant analyticsapp/—paymentStoregains chains, alerts and analytics;PaymentMethodsScreengains a chain builder with reordering and inline validation, a severity-coded expiry alert panel, and an analytics panelNotable behaviours:
fallbackRatereports the share of successful charges that only landed because the fallback existed — the number that says whether the chain is doing real workviewer/chargerroles with spend limits; an elapsed share behaves exactly like a revoked one, so no cleanup job is neededDocumentation
New:
docs/BATCH_OPERATIONS.md,docs/PLAN_TEMPLATES.md,docs/NOTIFICATIONS.md,docs/PAYMENT_METHODS.md.contracts/batch/BATCHING_API.mdwas rewritten — it documented a hook-based API (useBatchTransactions,addTransaction) that was never implemented.Verification
tsc --noEmitmainsrc/testsnavigationAnalytics.test.ts, a Babel parse error) also fails onmainmainstill fail, none newly197 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
maineither.soroban-sdk≤ 21.5 pinsethnum 1.5.0, which fails to compile on a modern rustc; ≥ 21.6 caps contract enum spec cases at 50, andCoreError(51 variants) andStorageKey(52) already exceed it, sosubtrackr-typesfails its#[contracterror]/#[contracttype]macros. No version combination compiles. Since CI usesdtolnay/rust-toolchain@master,npm run contracts:testis very likely already red.The Rust here was verified by review, and every changed file parses and is
rustfmt --checkclean — 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 toStorageKey(namespaced, rather than the six a flat layout would have needed) to keep the pressure minimal.2.
app/is excluded fromtsconfig.json,.eslintignoreand jest'stestPathIgnorePatterns.The 57 batch and payment-method tests under
app/pass, but need an override to run: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 bynpm test. Separately,.eslintignore'sservices/entry also matchessrc/services/, so that whole directory is silently unlinted.