Skip to content

Commit 799617c

Browse files
committed
feat: testnet-only subscription simulation endpoint
POST /api/subscriptions/{id}/simulate-period-end - Fast-forwards subscription period to the past for testing - with_payment: true simulates confirmed payment + fires subscription.renewed - with_payment: false (default) lets hourly job mark it past_due - Blocked on mainnet (403 Forbidden)
1 parent 34057b1 commit 799617c

2 files changed

Lines changed: 128 additions & 0 deletions

File tree

src/api/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
111111
"/subscriptions/{id}/cancel",
112112
web::post().to(subscriptions::cancel),
113113
)
114+
.route(
115+
"/subscriptions/{id}/simulate-period-end",
116+
web::post().to(subscriptions::simulate_period_end),
117+
)
114118
// Payment links (merchant auth)
115119
.route("/payment-links", web::post().to(payment_links::create))
116120
.route("/payment-links", web::get().to(payment_links::list))

src/api/subscriptions.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,127 @@ pub async fn cancel(
8686
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e.to_string()})),
8787
}
8888
}
89+
90+
/// Testnet-only: simulate subscription period ending (fast-forward for testing)
91+
/// POST /api/subscriptions/{id}/simulate-period-end
92+
#[derive(serde::Deserialize)]
93+
pub struct SimulateBody {
94+
/// If true, also simulate a confirmed payment (triggers renewal webhook)
95+
pub with_payment: Option<bool>,
96+
}
97+
98+
pub async fn simulate_period_end(
99+
req: HttpRequest,
100+
pool: web::Data<SqlitePool>,
101+
config: web::Data<Config>,
102+
path: web::Path<String>,
103+
body: web::Json<SimulateBody>,
104+
) -> HttpResponse {
105+
if !config.is_testnet() {
106+
return HttpResponse::Forbidden().json(serde_json::json!({
107+
"error": "Simulation endpoints are only available on testnet"
108+
}));
109+
}
110+
111+
let merchant = match super::auth::require_merchant_or_session(&req, pool.get_ref()).await {
112+
Ok(merchant) => merchant,
113+
Err(response) => return response,
114+
};
115+
116+
let sub_id = path.into_inner();
117+
118+
// Verify subscription belongs to merchant
119+
let sub = match subscriptions::get_subscription(pool.get_ref(), &sub_id).await {
120+
Ok(Some(s)) if s.merchant_id == merchant.id => s,
121+
Ok(Some(_)) => {
122+
return HttpResponse::Forbidden().json(serde_json::json!({
123+
"error": "Subscription does not belong to this merchant"
124+
}));
125+
}
126+
Ok(None) => {
127+
return HttpResponse::NotFound().json(serde_json::json!({
128+
"error": "Subscription not found"
129+
}));
130+
}
131+
Err(e) => {
132+
return HttpResponse::InternalServerError().json(serde_json::json!({
133+
"error": e.to_string()
134+
}));
135+
}
136+
};
137+
138+
if sub.status != "active" {
139+
return HttpResponse::BadRequest().json(serde_json::json!({
140+
"error": format!("Subscription is {}, not active", sub.status)
141+
}));
142+
}
143+
144+
// Fast-forward: set current_period_end to 1 hour ago
145+
let past = (chrono::Utc::now() - chrono::Duration::hours(1))
146+
.format("%Y-%m-%dT%H:%M:%SZ")
147+
.to_string();
148+
149+
if let Err(e) = sqlx::query("UPDATE subscriptions SET current_period_end = ? WHERE id = ?")
150+
.bind(&past)
151+
.bind(&sub_id)
152+
.execute(pool.get_ref())
153+
.await
154+
{
155+
return HttpResponse::InternalServerError().json(serde_json::json!({
156+
"error": e.to_string()
157+
}));
158+
}
159+
160+
let with_payment = body.with_payment.unwrap_or(false);
161+
162+
if with_payment {
163+
// Simulate a confirmed payment: advance the period and fire subscription.renewed
164+
match subscriptions::advance_subscription_period(pool.get_ref(), &sub_id).await {
165+
Ok(Some(new_sub)) => {
166+
let http = reqwest::Client::new();
167+
let payload = serde_json::json!({
168+
"subscription_id": new_sub.id,
169+
"invoice_id": "simulated",
170+
"new_period_start": new_sub.current_period_start,
171+
"new_period_end": new_sub.current_period_end,
172+
});
173+
let _ = crate::webhooks::dispatch_event(
174+
pool.get_ref(),
175+
&http,
176+
&merchant.id,
177+
"subscription.renewed",
178+
payload,
179+
&config.encryption_key,
180+
)
181+
.await;
182+
183+
return HttpResponse::Ok().json(serde_json::json!({
184+
"message": "Period ended and payment simulated — subscription.renewed webhook fired",
185+
"subscription": new_sub,
186+
}));
187+
}
188+
Ok(None) => {
189+
return HttpResponse::InternalServerError().json(serde_json::json!({
190+
"error": "Failed to advance subscription"
191+
}));
192+
}
193+
Err(e) => {
194+
return HttpResponse::InternalServerError().json(serde_json::json!({
195+
"error": e.to_string()
196+
}));
197+
}
198+
}
199+
}
200+
201+
// No payment simulation — just fast-forward and let the hourly job mark it past_due
202+
let updated_sub = subscriptions::get_subscription(pool.get_ref(), &sub_id)
203+
.await
204+
.ok()
205+
.flatten();
206+
207+
HttpResponse::Ok().json(serde_json::json!({
208+
"message": "Period fast-forwarded to past. Run process_renewals or wait for hourly job to mark past_due.",
209+
"subscription": updated_sub,
210+
"hint": "Use with_payment: true to simulate a confirmed renewal payment"
211+
}))
212+
}

0 commit comments

Comments
 (0)