Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 1 deletion src/memory/sync/composio/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ pub use gmail::GmailSyncPipeline;
pub use orchestrator::{run_incremental_sync, IncrementalSource, PageFetch, SyncItem, SyncScope};
pub use providers::{
ClickUpSyncPipeline, GitHubSyncPipeline, LinearSyncPipeline, NotionSyncPipeline,
SlackSearchBackfillPipeline, SlackSyncPipeline,
SlackSearchBackfillPipeline, SlackSyncPipeline, StripeSyncPipeline,
};
2 changes: 2 additions & 0 deletions src/memory/sync/composio/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ mod linear;
mod notion;
mod slack;
mod slack_parse;
mod stripe;

pub use clickup::ClickUpSyncPipeline;
pub use github::GitHubSyncPipeline;
pub use linear::LinearSyncPipeline;
pub use notion::NotionSyncPipeline;
pub use slack::{SlackSearchBackfillPipeline, SlackSyncPipeline};
pub use stripe::StripeSyncPipeline;
265 changes: 265 additions & 0 deletions src/memory/sync/composio/providers/stripe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
//! Incremental Stripe synchronization through Composio.
//!
//! Stripe is a document-shaped source: [`STRIPE_LIST_ALL_CHARGES`] returns a
//! Stripe list envelope (`{ "data": [charge, ...], "has_more": bool }`) whose
//! items each carry a stable object id (`ch_...`) and a `created` unix
//! timestamp. Pagination is cursor-based: the next page is requested with
//! `starting_after = <last item id>` while `has_more` is true, mirroring the
//! Stripe REST API.
//!
//! Charges are sensitive financial records, so nothing item-specific is logged
//! here; the shared orchestrator only emits toolkit/connection identifiers.

use async_trait::async_trait;
use serde_json::Value;

use super::common::{document, first_array, pick_str};
use crate::memory::config::MemoryConfig;
use crate::memory::sync::composio::{
run_incremental_sync, ActionExecutor, ComposioClient, IncrementalSource, PageFetch, SyncItem,
SyncScope,
};
use crate::memory::sync::state::SyncState;
use crate::memory::sync::traits::{
SkillDocument, SyncContext, SyncOutcome, SyncPipeline, SyncPipelineKind,
};

const ACTION_LIST_CHARGES: &str = "STRIPE_LIST_ALL_CHARGES";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'STRIPE_LIST' --type=rust
rg -n 'STRIPE_LIST' -g '*.json' -g '*.yaml' -g '*.yml' -g '*.toml'

Repository: tinyhumansai/tinycortex

Length of output: 230


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== relevant files ==\n'
git ls-files 'src/memory/sync/composio/providers/stripe.rs' 'src/**/*.rs' '*.json' '*.yaml' '*.yml' '*.toml' | sed -n '1,200p'

printf '\n== stripe provider ==\n'
sed -n '1,220p' src/memory/sync/composio/providers/stripe.rs

printf '\n== search composio/stripe slugs ==\n'
rg -n 'STRIPE_LIST_|composio|charges' src --glob '!**/target/**'

printf '\n== search repo-wide for exact slug and nearby variants ==\n'
rg -n 'STRIPE_LIST_ALL_CHARGES|STRIPE_LIST_CHARGES|LIST_ALL_CHARGES|LIST_CHARGES' .

printf '\n== file sizes for likely context ==\n'
wc -l src/memory/sync/composio/providers/stripe.rs

Repository: tinyhumansai/tinycortex

Length of output: 23065


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- stripe provider outline ---'
ast-grep outline src/memory/sync/composio/providers/stripe.rs --view expanded || true

echo
echo '--- stripe provider excerpt ---'
sed -n '1,140p' src/memory/sync/composio/providers/stripe.rs

echo
echo '--- exact slug searches ---'
rg -n --fixed-strings 'STRIPE_LIST_ALL_CHARGES' src .
rg -n --fixed-strings 'STRIPE_LIST_CHARGES' src .
rg -n --fixed-strings 'STRIPE_LIST_' src .

echo
echo '--- composio-related files ---'
git ls-files | rg 'composio|stripe|composio.*(json|ya?ml|toml)$|.*(json|ya?ml|toml)$'

Repository: tinyhumansai/tinycortex

Length of output: 6768


🌐 Web query:

Composio Stripe charges listing action slug STRIPE_LIST_CHARGES STRIPE_LIST_ALL_CHARGES

💡 Result:

In the Composio Stripe integration, the correct action slug for listing charges is STRIPE_LIST_CHARGES [1][2]. There is no standard action slug named STRIPE_LIST_ALL_CHARGES in the current Composio Stripe toolkit documentation [1][2]. The STRIPE_LIST_CHARGES action is designed to retrieve a list of Stripe charges and supports filtering and pagination [1][3]. When using this action, it is recommended to use valid cursor IDs from previous responses for pagination, as charges are typically returned in reverse chronological order [1][4]. Common parameters used with STRIPE_LIST_CHARGES include filters such as 'customer' and 'limit' [2][5].

Citations:


Use the Composio Stripe charges slug. STRIPE_LIST_ALL_CHARGES should be STRIPE_LIST_CHARGES; otherwise the action lookup fails and Stripe syncs stop working.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/memory/sync/composio/providers/stripe.rs` at line 27, Update the
ACTION_LIST_CHARGES constant to use the Composio Stripe charges slug
STRIPE_LIST_CHARGES instead of STRIPE_LIST_ALL_CHARGES, preserving the existing
action lookup flow.


pub struct StripeSyncPipeline {
client: ComposioClient,
connection_id: String,
max_pages: usize,
page_size: usize,
}

impl StripeSyncPipeline {
pub fn new(client: ComposioClient, connection_id: impl Into<String>) -> Self {
Self {
client,
connection_id: connection_id.into(),
max_pages: 20,
page_size: 50,
}
}

pub fn with_limits(mut self, max_pages: usize, page_size: usize) -> Self {
self.max_pages = max_pages.max(1);
self.page_size = page_size.max(1);
self
}
Comment on lines +46 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== stripe provider file ==\n'
sed -n '1,220p' src/memory/sync/composio/providers/stripe.rs

printf '\n== search for page_size usage ==\n'
rg -n "page_size|max_pages|with_limits|limit" src/memory/sync/composio/providers src/memory/sync -g '!**/target/**'

Repository: tinyhumansai/tinycortex

Length of output: 19698


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Focus on the target file and its immediate context.
nl -ba src/memory/sync/composio/providers/stripe.rs | sed -n '1,220p'

printf '\n== page_size references in stripe provider subtree ==\n'
rg -n "page_size|max_pages|with_limits|limit" src/memory/sync/composio/providers/stripe.rs src/memory/sync/composio/providers -g '!**/target/**'

Repository: tinyhumansai/tinycortex

Length of output: 201


🌐 Web query:

Stripe API list pagination limit parameter maximum 100 charges list documentation

💡 Result:

For the Stripe API, the limit parameter used in list endpoints, including the charges list, has a maximum allowed value of 100 [1][2][3]. The value must be an integer between 1 and 100, with a default value of 10 if no limit is specified [1][4][5]. To retrieve more than 100 objects, you should use cursor-based pagination with the starting_after or ending_before parameters [2][3].

Citations:


Clamp page_size to 100. Stripe list endpoints reject limit values above 100, so larger values passed through with_limits will make every page request fail.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/memory/sync/composio/providers/stripe.rs` around lines 46 - 50, Update
with_limits so page_size is clamped to a maximum of 100 while retaining the
existing minimum of 1, ensuring values passed to Stripe remain within the
accepted range. Leave max_pages handling unchanged.

}

#[async_trait]
impl SyncPipeline for StripeSyncPipeline {
fn id(&self) -> &str {
"composio:stripe"
}
fn kind(&self) -> SyncPipelineKind {
SyncPipelineKind::Composio
}
async fn init(&self, _: &MemoryConfig, _: &SyncContext) -> anyhow::Result<()> {
Ok(())
}
async fn tick(
&self,
config: &MemoryConfig,
context: &SyncContext,
) -> anyhow::Result<SyncOutcome> {
run_incremental_sync(self, &self.client, &self.connection_id, config, context).await
}
}

#[async_trait]
impl IncrementalSource for StripeSyncPipeline {
fn toolkit(&self) -> &'static str {
"stripe"
}
fn action(&self) -> &'static str {
ACTION_LIST_CHARGES
}
fn max_pages(&self) -> usize {
self.max_pages
}
fn arguments(
&self,
_: &SyncScope,
_: &MemoryConfig,
_: &SyncState,
page: Option<&str>,
) -> Value {
let mut args = serde_json::json!({"limit": self.page_size});
if let Some(page) = page {
args["starting_after"] = serde_json::json!(page);
}
args
}
fn extract_page(&self, data: &Value, _: Option<&str>) -> PageFetch {
let items = first_array(
data,
&[
"/data/data",
"/data/data/data",
"/data/response_data/data",
"/data/results",
"/results",
"/data/items",
"/items",
],
);
// Stripe cursor pagination: request the next page with the last object's
// id as `starting_after`, but only while the list reports `has_more`.
let has_more = ["/data/has_more", "/has_more", "/data/data/has_more"]
.iter()
.find_map(|path| data.pointer(path).and_then(Value::as_bool))
.unwrap_or(false);
let next = has_more
.then(|| {
items
.last()
.and_then(|item| pick_str(item, &["id", "data.id"]))
})
.flatten();
PageFetch { items, next }
}
fn dedup_key(&self, item: &Value) -> Option<String> {
let id = pick_str(item, &["id", "data.id"])?;
Some(match self.sort_cursor(item) {
Some(created) => format!("{id}@{created}"),
None => id,
})
}
fn sort_cursor(&self, item: &Value) -> Option<String> {
pick_str(item, &["created", "data.created"])
}
async fn document(
&self,
_: &SyncScope,
connection_id: &str,
item: SyncItem,
_: &dyn ActionExecutor,
_: &mut SyncState,
) -> anyhow::Result<SkillDocument> {
// The Stripe object id (`ch_...`) is the stable upsert key. Never derive
// `document_id` from a per-run cursor: that reintroduces the duplicate
// charges fixed by tinyhumansai/openhuman#4953.
let id = pick_str(&item.raw, &["id", "data.id"]).unwrap_or_else(|| item.dedup_key.clone());
let title = pick_str(
&item.raw,
&[
"description",
"data.description",
"statement_descriptor",
"data.statement_descriptor",
],
)
.unwrap_or_else(|| format!("Stripe charge {id}"));
let content = serde_json::to_string_pretty(&item.raw)?;
Ok(document(
"stripe",
connection_id,
&id,
title,
content,
item.raw,
))
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::memory::config::{ComposioMode, ComposioSyncConfig};

fn pipeline() -> StripeSyncPipeline {
let config = ComposioSyncConfig {
mode: ComposioMode::Direct,
base_url: "http://localhost".into(),
api_key: None,
bearer_token: None,
entity_id: None,
};
StripeSyncPipeline::new(ComposioClient::new(config), "conn-test")
}

fn sample_payload() -> Value {
// Composio wraps the Stripe list envelope under its own `data` key.
serde_json::json!({
"successful": true,
"data": {
"object": "list",
"has_more": true,
"data": [
{"id": "ch_1", "object": "charge", "created": 1700000001, "amount": 500, "description": "Pro plan"},
{"id": "ch_2", "object": "charge", "created": 1700000002, "amount": 900}
]
}
})
}

#[test]
fn toolkit_and_action_match_composio_slug() {
let pipeline = pipeline();
assert_eq!(pipeline.toolkit(), "stripe");
assert_eq!(pipeline.action(), "STRIPE_LIST_ALL_CHARGES");
}

#[test]
fn extract_page_reads_charges_and_cursor() {
let pipeline = pipeline();
let page = pipeline.extract_page(&sample_payload(), None);
assert_eq!(page.items.len(), 2);
// `starting_after` for the next request is the last charge's id.
assert_eq!(page.next.as_deref(), Some("ch_2"));
}

#[test]
fn extract_page_stops_without_has_more() {
let pipeline = pipeline();
let data = serde_json::json!({
"data": {"has_more": false, "data": [{"id": "ch_9", "created": 1700000009}]}
});
let page = pipeline.extract_page(&data, None);
assert_eq!(page.items.len(), 1);
assert!(page.next.is_none());
}

#[test]
fn dedup_key_combines_id_and_created() {
let pipeline = pipeline();
let item = serde_json::json!({"id": "ch_1", "created": 1700000001});
assert_eq!(
pipeline.dedup_key(&item).as_deref(),
Some("ch_1@1700000001")
);
}

#[tokio::test]
async fn document_uses_stable_document_id_and_taint() {
let pipeline = pipeline();
let mut state = SyncState::new("stripe", "conn-test");
let raw =
serde_json::json!({"id": "ch_1", "created": 1700000001, "description": "Pro plan"});
let item = SyncItem {
dedup_key: "ch_1@1700000001".into(),
sort_cursor: Some("1700000001".into()),
raw,
};
let doc = pipeline
.document(
&SyncScope::flat(),
"conn-test",
item,
&pipeline.client,
&mut state,
)
.await
.unwrap();
// Stable upsert key: derived from the object id, not the per-run cursor.
assert_eq!(doc.document_id, "stripe:ch_1");
assert_eq!(doc.namespace_skill_id, "stripe");
assert_eq!(doc.toolkit, "stripe");
assert_eq!(doc.title, "Pro plan");
assert_eq!(doc.metadata["taint"], "external_sync");
}
}
Comment on lines +169 to +265

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move tests to a sibling stripe_tests.rs file.

The #[cfg(test)] mod tests block is embedded directly in stripe.rs. As per coding guidelines, src/**/*.rs should "Keep tests in per-file <name>_tests.rs siblings, such as store.rs and store_tests.rs, rather than mixing tests into implementation files."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/memory/sync/composio/providers/stripe.rs` around lines 169 - 265, The
tests are embedded in the implementation file instead of a sibling test module.
Move the entire #[cfg(test)] mod tests block, including pipeline,
sample_payload, and all test functions, into a sibling stripe_tests.rs file;
import the implementation symbols there as needed and remove the embedded test
module from stripe.rs.

Source: Coding guidelines

1 change: 1 addition & 0 deletions src/memory/sync/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub use composio::{
resolve_auth_config_id, status_is_active, status_is_terminal, ClickUpSyncPipeline,
ComposioClient, ConnectionLink, EntityStore, GitHubSyncPipeline, GmailSyncPipeline,
LinearSyncPipeline, NotionSyncPipeline, SlackSearchBackfillPipeline, SlackSyncPipeline,
StripeSyncPipeline,
};
pub use dispatcher::{SyncDispatcher, SyncRunResult};
pub use github::GithubRepoSyncPipeline;
Expand Down
Loading