open-standards/openpay/crates/op-subscriptions/src/store.rs
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

293 lines
8.9 KiB
Rust

//! Pluggable subscription storage.
//!
//! Same shape as the other domain stores: a sync trait plus an
//! [`InMemorySubscriptionStore`] reference impl. A graph-backed
//! impl lives in `op-graph`.
use std::collections::HashMap;
use std::sync::Mutex;
use crate::error::{Error, Result};
use crate::subscription::{Subscription, SubscriptionId};
/// Pluggable subscription storage interface.
pub trait SubscriptionStore: Send + Sync {
/// Persist a fresh subscription. Idempotent on `external_id`.
///
/// # Errors
/// See [`Error`].
fn create_subscription(&self, s: Subscription) -> Result<SubscriptionId>;
/// Fetch by id.
///
/// # Errors
/// [`Error::NotFound`] if unknown.
fn get_subscription(&self, id: SubscriptionId) -> Result<Subscription>;
/// Find by external id.
///
/// # Errors
/// Backend-specific.
fn find_by_external_id(&self, external_id: &str) -> Result<Option<Subscription>>;
/// All subscriptions for a customer. Order unspecified.
///
/// # Errors
/// Backend-specific.
fn list_for_customer(&self, customer_ref: &str) -> Result<Vec<Subscription>>;
/// All non-terminal subscriptions whose
/// `current_period_end_unix_secs <= as_of`. Used by the
/// billing tick to fan out due-charge events.
///
/// # Errors
/// Backend-specific.
fn list_due_at(&self, as_of_unix_secs: u64) -> Result<Vec<Subscription>>;
/// Apply a state transition via closure. Commits only on
/// `Ok(())`.
///
/// # Errors
/// `Error::NotFound`, plus whatever the closure returns.
fn update<F>(&self, id: SubscriptionId, f: F) -> Result<Subscription>
where
F: FnOnce(&mut Subscription) -> Result<()>;
}
// ============================================================
// In-memory ref impl
// ============================================================
#[derive(Default)]
struct Inner {
subs: HashMap<SubscriptionId, Subscription>,
by_external_id: HashMap<String, SubscriptionId>,
}
/// In-process subscription store. Not for multi-instance production.
#[derive(Default)]
pub struct InMemorySubscriptionStore {
inner: Mutex<Inner>,
}
impl InMemorySubscriptionStore {
/// Construct empty.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Number of subscriptions tracked.
///
/// # Panics
/// Panics only if the internal mutex was previously poisoned
/// by a thread that panicked while holding it.
#[must_use]
pub fn len(&self) -> usize {
self.inner.lock().expect("poisoned").subs.len()
}
/// True if no subscriptions stored.
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
fn bodies_equivalent(a: &Subscription, b: &Subscription) -> bool {
a.customer_ref == b.customer_ref
&& a.plan.id == b.plan.id
&& a.plan.amount == b.plan.amount
&& a.plan.interval == b.plan.interval
&& a.plan.interval_count == b.plan.interval_count
&& a.external_id == b.external_id
}
impl SubscriptionStore for InMemorySubscriptionStore {
fn create_subscription(&self, s: Subscription) -> Result<SubscriptionId> {
let mut inner = self.inner.lock().expect("poisoned");
if let Some(ext) = &s.external_id
&& let Some(existing_id) = inner.by_external_id.get(ext).copied()
{
let existing = inner
.subs
.get(&existing_id)
.expect("index/store invariant")
.clone();
if bodies_equivalent(&existing, &s) {
return Ok(existing_id);
}
return Err(Error::IdempotencyMismatch(ext.clone()));
}
if let Some(ext) = &s.external_id {
inner.by_external_id.insert(ext.clone(), s.id);
}
let id = s.id;
inner.subs.insert(id, s);
Ok(id)
}
fn get_subscription(&self, id: SubscriptionId) -> Result<Subscription> {
self.inner
.lock()
.expect("poisoned")
.subs
.get(&id)
.cloned()
.ok_or_else(|| Error::NotFound(id.to_string()))
}
fn find_by_external_id(&self, external_id: &str) -> Result<Option<Subscription>> {
let inner = self.inner.lock().expect("poisoned");
let Some(id) = inner.by_external_id.get(external_id).copied() else {
return Ok(None);
};
Ok(inner.subs.get(&id).cloned())
}
fn list_for_customer(&self, customer_ref: &str) -> Result<Vec<Subscription>> {
Ok(self
.inner
.lock()
.expect("poisoned")
.subs
.values()
.filter(|s| s.customer_ref == customer_ref)
.cloned()
.collect())
}
fn list_due_at(&self, as_of_unix_secs: u64) -> Result<Vec<Subscription>> {
Ok(self
.inner
.lock()
.expect("poisoned")
.subs
.values()
.filter(|s| {
!s.status.is_terminal() && s.current_period_end_unix_secs <= as_of_unix_secs
})
.cloned()
.collect())
}
fn update<F>(&self, id: SubscriptionId, f: F) -> Result<Subscription>
where
F: FnOnce(&mut Subscription) -> Result<()>,
{
let mut inner = self.inner.lock().expect("poisoned");
let existing = inner
.subs
.get(&id)
.ok_or_else(|| Error::NotFound(id.to_string()))?;
let mut staged = existing.clone();
f(&mut staged)?;
let result = staged.clone();
inner.subs.insert(id, staged);
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plan::{Interval, Plan};
use op_core::{Currency, Money, PaymentMethod, VaultRef};
fn plan() -> Plan {
Plan::new(
"p",
Money::from_minor(1000, Currency::USD),
Interval::Month,
1,
)
.unwrap()
}
fn sub(customer: &str, ext: Option<&str>) -> Subscription {
let mut s = Subscription::new(
customer,
plan(),
PaymentMethod::Vault(VaultRef::new("tok")),
1_700_000_000,
)
.unwrap();
if let Some(e) = ext {
s = s.with_external_id(e);
}
s
}
#[test]
fn round_trip() {
let store = InMemorySubscriptionStore::new();
let s = sub("c-1", None);
let id = s.id;
store.create_subscription(s.clone()).unwrap();
let got = store.get_subscription(id).unwrap();
assert_eq!(got.id, id);
assert_eq!(got.customer_ref, s.customer_ref);
assert_eq!(got.plan.id, s.plan.id);
assert_eq!(got.status.code(), s.status.code());
}
#[test]
fn idempotency_same_body() {
let store = InMemorySubscriptionStore::new();
let s = sub("c-1", Some("e1"));
let id1 = store.create_subscription(s.clone()).unwrap();
let id2 = store.create_subscription(s).unwrap();
assert_eq!(id1, id2);
}
#[test]
fn idempotency_mismatch_rejects() {
let store = InMemorySubscriptionStore::new();
store.create_subscription(sub("c-1", Some("e1"))).unwrap();
assert!(matches!(
store
.create_subscription(sub("c-2", Some("e1")))
.unwrap_err(),
Error::IdempotencyMismatch(_)
));
}
#[test]
fn list_for_customer_filters() {
let store = InMemorySubscriptionStore::new();
store.create_subscription(sub("c-1", Some("a"))).unwrap();
store.create_subscription(sub("c-1", Some("b"))).unwrap();
store.create_subscription(sub("c-2", Some("c"))).unwrap();
assert_eq!(store.list_for_customer("c-1").unwrap().len(), 2);
assert_eq!(store.list_for_customer("c-2").unwrap().len(), 1);
assert_eq!(store.list_for_customer("nope").unwrap().len(), 0);
}
#[test]
fn list_due_at_filters_by_period_end() {
let store = InMemorySubscriptionStore::new();
let s = sub("c-1", None);
let period_end = s.current_period_end_unix_secs;
store.create_subscription(s).unwrap();
assert!(store.list_due_at(period_end - 1).unwrap().is_empty());
assert_eq!(store.list_due_at(period_end + 1).unwrap().len(), 1);
}
#[test]
fn update_commits_on_ok_only() {
let store = InMemorySubscriptionStore::new();
let s = sub("c-1", None);
let id = s.id;
store.create_subscription(s).unwrap();
store.update(id, |s| s.pause(2_000)).unwrap();
assert_eq!(store.get_subscription(id).unwrap().status.code(), "paused");
let _ = store
.update(id, |s| {
s.resume()?;
Err(Error::Invalid("rollback".into()))
})
.unwrap_err();
// Closure failed; resume() side effect was discarded.
assert_eq!(store.get_subscription(id).unwrap().status.code(), "paused");
}
}