@@ -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