Skip to main content

pharos_native/runtime/
payment_provider.rs

1//! App-neutral payment-provider contracts.
2//!
3//! This module is deliberately independent of any proving app. Apps supply
4//! owner and route mappings through their manifest; adapters supply provider
5//! protocol behavior; the lifecycle service joins those contracts.
6
7#[cfg(test)]
8use std::collections::BTreeMap;
9use std::env;
10#[cfg(test)]
11use std::fs;
12use std::path::Path;
13use std::time::{SystemTime, UNIX_EPOCH};
14#[cfg(test)]
15use std::{cell::RefCell, collections::VecDeque};
16
17use serde_json::{Map, Value};
18
19struct RuntimePaymentProviderConfigSource<'a> {
20    app_root: &'a Path,
21    runtime_conf_path: Option<&'a Path>,
22}
23
24impl pharos_runtime_payment::PaymentProviderConfigSource
25    for RuntimePaymentProviderConfigSource<'_>
26{
27    fn config_value(&self, key: &str) -> Option<String> {
28        super::json_commands::dynamic_config_value(self.app_root, self.runtime_conf_path, key)
29    }
30}
31
32impl pharos_runtime_payment::PaymentProviderProtocolConfig
33    for RuntimePaymentProviderConfigSource<'_>
34{
35    fn secret_env_value(&self, key: &str) -> Option<String> {
36        payment_provider_secret_env_value(self.app_root, self.runtime_conf_path, key)
37    }
38}
39
40impl pharos_runtime_payment::PaymentProviderSecretKeyResolver
41    for RuntimePaymentProviderConfigSource<'_>
42{
43    fn active_secret_key_version(&self) -> Option<String> {
44        payment_provider_active_secret_key_version(self.app_root, self.runtime_conf_path)
45    }
46
47    fn encryption_key_for_version(&self, key_version: &str) -> Option<[u8; 32]> {
48        payment_provider_encryption_key_for_version(
49            self.app_root,
50            self.runtime_conf_path,
51            key_version,
52        )
53    }
54}
55
56impl pharos_runtime_payment::PaymentProviderBookingAdapter
57    for RuntimePaymentProviderConfigSource<'_>
58{
59    fn business_open_for_slot(
60        &self,
61        root: &Value,
62        business_id: i64,
63        date_value: &str,
64        time_value: &str,
65        duration_minutes: i64,
66    ) -> bool {
67        super::business::dynamic_business_open_for_slot(
68            root,
69            business_id,
70            date_value,
71            time_value,
72            duration_minutes,
73        )
74    }
75
76    fn assign_technician_for_slot(
77        &self,
78        root: &Value,
79        business_id: i64,
80        service_id: i64,
81        date_value: &str,
82        time_value: &str,
83    ) -> i64 {
84        super::business::dynamic_assign_technician_for_slot(
85            root,
86            business_id,
87            service_id,
88            date_value,
89            time_value,
90        )
91    }
92}
93
94impl pharos_runtime_payment::PaymentProviderHttpClient for RuntimePaymentProviderConfigSource<'_> {
95    fn request_json(
96        &self,
97        method: &str,
98        url: &str,
99        content_type: Option<&str>,
100        payload: &str,
101        extra_headers: &[(&str, String)],
102        allow_plaintext_local: bool,
103    ) -> Result<(u16, Value), i32> {
104        stripe_request_json(
105            method,
106            url,
107            content_type,
108            payload,
109            extra_headers,
110            allow_plaintext_local,
111        )
112    }
113}
114
115fn payment_provider_config_source<'a>(
116    app_root: &'a Path,
117    runtime_conf_path: Option<&'a Path>,
118) -> RuntimePaymentProviderConfigSource<'a> {
119    RuntimePaymentProviderConfigSource {
120        app_root,
121        runtime_conf_path,
122    }
123}
124
125/// Verifies provider input before passing its scoped mutation to the database interface.
126pub fn database_checkout_webhook_complete(
127    app_root: &Path,
128    runtime_conf_path: Option<&Path>,
129    store: &super::database::LogicalAppStore,
130    provider: &str,
131    headers: &str,
132    body: &[u8],
133) -> Result<(), i32> {
134    let environment = payment_provider_config_source(app_root, runtime_conf_path);
135    let now = SystemTime::now()
136        .duration_since(UNIX_EPOCH)
137        .map_err(|_| 503)?
138        .as_secs();
139    let Some((kind, references)) =
140        pharos_runtime_payment::verified_checkout_event(&environment, headers, body, now)?
141    else {
142        return Ok(());
143    };
144    let checkout = pharos_runtime_payment::checkout_manifest_config(app_root).ok_or(404)?;
145    let checkout_kind =
146        pharos_runtime_payment::checkout_manifest_string(&checkout, "kind", "commerce_cart");
147    pharos_runtime_payment::database::apply_checkout_event(
148        store.adapter.as_ref(),
149        &store.config,
150        &store.schema,
151        pharos_runtime_payment::database::CheckoutEvent {
152            kind,
153            checkout_kind: &checkout_kind,
154            provider,
155            references: &references,
156        },
157    )
158    .map_err(|error| match error {
159        pharos_runtime_database::operations::DatabaseError::InvalidInput => 422,
160        pharos_runtime_database::operations::DatabaseError::MissingRecord => 404,
161        pharos_runtime_database::operations::DatabaseError::Conflict => 409,
162        _ => 503,
163    })
164}
165
166/// Runs provider checkout through the configured operation interface.
167pub fn database_checkout(
168    app_root: &Path,
169    runtime_conf_path: Option<&Path>,
170    store: &super::database::LogicalAppStore,
171    completion_adapter: &dyn pharos_runtime_database::operations::DatabaseOperations,
172    provider: &str,
173    request: PaymentProviderCheckoutRequest,
174) -> Result<PaymentProviderCheckoutStart, i32> {
175    let checkout = pharos_runtime_payment::checkout_manifest_config(app_root).ok_or(404)?;
176    let kind = pharos_runtime_payment::checkout_manifest_string(&checkout, "kind", "commerce_cart");
177    let environment = payment_provider_config_source(app_root, runtime_conf_path);
178    let database = pharos_runtime_payment::database::CheckoutDatabase {
179        adapter: store.adapter.as_ref(),
180        completion_adapter,
181        config: &store.config,
182        schema: &store.schema,
183    };
184    match kind.as_str() {
185        "commerce_cart" => pharos_runtime_payment::database::runtime_commerce_checkout(
186            database,
187            app_root,
188            &environment,
189            provider,
190            request,
191        ),
192        "appointment_booking" => pharos_runtime_payment::database::runtime_appointment_checkout(
193            database,
194            app_root,
195            &environment,
196            provider,
197            request,
198        ),
199        _ => Err(422),
200    }
201}
202
203pub type ConnectionState = pharos_runtime_payment::PaymentProviderConnectionState;
204
205pub type PaymentProviderOperation = pharos_runtime_payment::PaymentProviderOperation;
206pub type PaymentProviderCheckoutOutcome = pharos_runtime_payment::PaymentProviderCheckoutOutcome;
207pub type PaymentProviderCheckoutStart = pharos_runtime_payment::PaymentProviderCheckoutStart;
208pub type PaymentProviderCheckoutRequest = pharos_runtime_payment::PaymentProviderCheckoutRequest;
209pub type PaymentProviderRouteBinding = pharos_runtime_payment::PaymentProviderRouteBinding;
210pub type PaymentProviderCredentialField = pharos_runtime_payment::PaymentProviderCredentialField;
211pub type PaymentProviderResolvedCredentials =
212    pharos_runtime_payment::PaymentProviderResolvedCredentials;
213pub type PaymentProviderCredentialSubmission =
214    pharos_runtime_payment::PaymentProviderCredentialSubmission;
215pub use pharos_runtime_payment::{
216    ManifestOwnerResolver, PaymentProviderAccountMutation, PaymentProviderAccountStore,
217    PaymentProviderAccountUpdate, PaymentProviderAdapter, PaymentProviderAdapterRegistry,
218    PaymentProviderAuditEvent, PaymentProviderCallbackNonce, PaymentProviderLifecycleService,
219    PaymentProviderOwner, PaymentProviderOwnerResolver, actor_owns_declared_payment_provider_route,
220    payment_provider_account_row, payment_provider_account_row_mut,
221    payment_provider_default_owner_route_value, payment_provider_owner_from_route,
222    payment_provider_owner_payload, payment_provider_owner_route_value, payment_provider_row_str,
223    resolve_manifest_owner,
224};
225
226pub fn declared_operation(
227    management: &Value,
228    action_id: &str,
229) -> Option<(String, PaymentProviderOperation)> {
230    pharos_runtime_payment::declared_operation(management, action_id)
231}
232
233pub fn declared_route_binding(
234    management: &Value,
235    action_id: &str,
236) -> Option<PaymentProviderRouteBinding> {
237    pharos_runtime_payment::declared_route_binding(management, action_id)
238}
239
240#[cfg(test)]
241type PaymentProviderConnectionState = ConnectionState;
242#[cfg(test)]
243fn payment_provider_manifest_config(app_root: &Path) -> Option<Value> {
244    pharos_runtime_payment::payment_provider_manifest_config(app_root)
245}
246
247pub fn payment_provider_copy_value(app_root: &Path, key: &str) -> Option<String> {
248    pharos_runtime_payment::payment_provider_copy_value(app_root, key)
249}
250
251pub fn payment_provider_copy_entries(app_root: &Path) -> Option<Map<String, Value>> {
252    pharos_runtime_payment::payment_provider_copy_entries(app_root)
253}
254
255pub fn payment_provider_template_params(
256    root: &Value,
257    app_root: &Path,
258    owner_route_value: &str,
259    provider: &str,
260) -> serde_json::Map<String, Value> {
261    pharos_runtime_payment::payment_provider_template_params(
262        root,
263        app_root,
264        owner_route_value,
265        provider,
266    )
267}
268
269pub fn payment_provider_render_params(
270    root: &Value,
271    app_root: &Path,
272    request_path: &str,
273) -> serde_json::Map<String, Value> {
274    pharos_runtime_payment::payment_provider_render_params(root, app_root, request_path)
275}
276
277pub fn payment_provider_declared_credential_fields(
278    app_root: &Path,
279    provider: &str,
280) -> Vec<PaymentProviderCredentialField> {
281    pharos_runtime_payment::payment_provider_declared_credential_fields(app_root, provider)
282}
283
284pub fn payment_provider_return_path(app_root: &Path, owner_route_value: &str) -> Option<String> {
285    pharos_runtime_payment::payment_provider_return_path(app_root, owner_route_value)
286}
287
288pub fn payment_provider_setup_path(
289    app_root: &Path,
290    owner_route_value: &str,
291    provider: &str,
292) -> Option<String> {
293    pharos_runtime_payment::payment_provider_setup_path(app_root, owner_route_value, provider)
294}
295
296pub type PaymentProviderLifecycleAction = pharos_runtime_payment::PaymentProviderLifecycleAction;
297pub type PaymentProviderConnectRequest<'a> =
298    pharos_runtime_payment::PaymentProviderConnectRequest<'a>;
299
300pub fn payment_provider_callback_action_declared(app_root: &Path, action_id: &str) -> bool {
301    pharos_runtime_payment::payment_provider_callback_action_declared(app_root, action_id)
302}
303
304pub fn payment_provider_lifecycle_action_declared(
305    app_root: &Path,
306    action_id: &str,
307) -> Option<(String, PaymentProviderLifecycleAction)> {
308    pharos_runtime_payment::payment_provider_lifecycle_action_declared(app_root, action_id)
309}
310
311pub fn payment_provider_checkout_action_declared(
312    app_root: &Path,
313    action_id: &str,
314) -> Option<String> {
315    pharos_runtime_payment::payment_provider_checkout_action_declared(app_root, action_id)
316}
317
318pub fn payment_provider_checkout_webhook_action_declared(
319    app_root: &Path,
320    action_id: &str,
321) -> Option<String> {
322    pharos_runtime_payment::payment_provider_checkout_webhook_action_declared(app_root, action_id)
323}
324
325pub fn persist_payment_provider_state_or_500(root: &Value, state_path: &Path) -> Result<(), i32> {
326    pharos_runtime_payment::persist_payment_provider_state_or_500(root, state_path)
327}
328
329fn payment_provider_config_value(
330    app_root: &Path,
331    runtime_conf_path: Option<&Path>,
332    key: &str,
333) -> Option<String> {
334    let source = payment_provider_config_source(app_root, runtime_conf_path);
335    pharos_runtime_payment::payment_provider_config_value(&source, key)
336}
337
338fn payment_provider_secret_env_value(
339    app_root: &Path,
340    runtime_conf_path: Option<&Path>,
341    config_key: &str,
342) -> Option<String> {
343    let env_key = payment_provider_config_value(app_root, runtime_conf_path, config_key)?;
344    env::var(env_key).ok().filter(|value| !value.is_empty())
345}
346
347fn payment_provider_active_secret_key_version(
348    app_root: &Path,
349    runtime_conf_path: Option<&Path>,
350) -> Option<String> {
351    let version = payment_provider_config_value(
352        app_root,
353        runtime_conf_path,
354        "payment_provider_secret_key_version",
355    )
356    .unwrap_or_else(|| "v1".to_owned());
357    (version.len() <= 32
358        && version
359            .bytes()
360            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'))
361    .then_some(version)
362}
363
364fn payment_provider_encryption_key_for_version(
365    app_root: &Path,
366    runtime_conf_path: Option<&Path>,
367    key_version: &str,
368) -> Option<[u8; 32]> {
369    let active_version = payment_provider_active_secret_key_version(app_root, runtime_conf_path)?;
370    let config_key = if key_version == active_version {
371        "payment_provider_secret_key_env"
372    } else if payment_provider_config_value(
373        app_root,
374        runtime_conf_path,
375        "payment_provider_secret_previous_key_version",
376    )
377    .as_deref()
378        == Some(key_version)
379    {
380        "payment_provider_secret_previous_key_env"
381    } else {
382        return None;
383    };
384    let env_key = payment_provider_config_value(app_root, runtime_conf_path, config_key)?;
385    #[cfg(test)]
386    if env_key == "__pharos_test_payment_provider_secret_key__" {
387        return Some([
388            0x9f, 0x5b, 0xe7, 0xf6, 0x65, 0xc2, 0xb5, 0xe3, 0x41, 0xdf, 0xf8, 0xf2, 0x99, 0xb3,
389            0xd2, 0xf1, 0xf3, 0x44, 0x9a, 0x90, 0xe4, 0x06, 0x66, 0x35, 0x0e, 0x72, 0x7b, 0x90,
390            0x54, 0x15, 0x8a, 0x62,
391        ]);
392    }
393    #[cfg(test)]
394    if env_key == "__pharos_test_payment_provider_secret_key_v2__" {
395        return Some([
396            0x76, 0x1c, 0xbf, 0x2d, 0x93, 0xa8, 0x45, 0xf1, 0x57, 0x0e, 0xc4, 0x2b, 0xd9, 0x68,
397            0x31, 0x6a, 0x04, 0xbe, 0x87, 0xf5, 0x25, 0x73, 0xca, 0x18, 0xed, 0x52, 0x9b, 0x3a,
398            0x7c, 0xe1, 0x60, 0x94,
399        ]);
400    }
401    pharos_runtime_payment::hex_decode_fixed::<32>(&env::var(env_key).ok()?)
402}
403
404pub fn upsert_payment_provider_secret(
405    root: &mut Value,
406    app_root: &Path,
407    runtime_conf_path: Option<&Path>,
408    app_id: &str,
409    tenant_id: i64,
410    owner_resource_type: &str,
411    owner_resource_id: i64,
412    provider: &str,
413    secret_kind: &str,
414    plaintext: &str,
415) -> Result<i64, i32> {
416    let source = payment_provider_config_source(app_root, runtime_conf_path);
417    pharos_runtime_payment::upsert_payment_provider_secret(
418        root,
419        &source,
420        app_id,
421        tenant_id,
422        owner_resource_type,
423        owner_resource_id,
424        provider,
425        secret_kind,
426        plaintext,
427    )
428}
429
430pub fn read_payment_provider_secret(
431    root: &Value,
432    app_root: &Path,
433    runtime_conf_path: Option<&Path>,
434    app_id: &str,
435    tenant_id: i64,
436    owner_resource_type: &str,
437    owner_resource_id: i64,
438    provider: &str,
439    secret_kind: &str,
440) -> Result<String, i32> {
441    let source = payment_provider_config_source(app_root, runtime_conf_path);
442    pharos_runtime_payment::read_payment_provider_secret(
443        root,
444        &source,
445        app_id,
446        tenant_id,
447        owner_resource_type,
448        owner_resource_id,
449        provider,
450        secret_kind,
451    )
452}
453
454pub fn rotate_payment_provider_secret(
455    root: &mut Value,
456    app_root: &Path,
457    runtime_conf_path: Option<&Path>,
458    app_id: &str,
459    tenant_id: i64,
460    owner_resource_type: &str,
461    owner_resource_id: i64,
462    provider: &str,
463    secret_kind: &str,
464) -> Result<i64, i32> {
465    let source = payment_provider_config_source(app_root, runtime_conf_path);
466    pharos_runtime_payment::rotate_payment_provider_secret(
467        root,
468        &source,
469        app_id,
470        tenant_id,
471        owner_resource_type,
472        owner_resource_id,
473        provider,
474        secret_kind,
475    )
476}
477
478pub fn revoke_payment_provider_secret(
479    root: &mut Value,
480    app_id: &str,
481    tenant_id: i64,
482    owner_resource_type: &str,
483    owner_resource_id: i64,
484    provider: &str,
485    secret_kind: &str,
486) -> Result<(), i32> {
487    pharos_runtime_payment::revoke_payment_provider_secret(
488        root,
489        app_id,
490        tenant_id,
491        owner_resource_type,
492        owner_resource_id,
493        provider,
494        secret_kind,
495    )
496}
497
498fn stripe_request_json(
499    method: &str,
500    url: &str,
501    content_type: Option<&str>,
502    payload: &str,
503    extra_headers: &[(&str, String)],
504    allow_plaintext_local: bool,
505) -> Result<(u16, Value), i32> {
506    #[cfg(test)]
507    if let Some(response) = stripe_test_take_response() {
508        return response;
509    }
510    pharos_runtime_payment::stripe_request_json(
511        method,
512        url,
513        content_type,
514        payload,
515        extra_headers,
516        allow_plaintext_local,
517    )
518}
519
520#[cfg(test)]
521thread_local! {
522    static STRIPE_TEST_RESPONSES: RefCell<VecDeque<Result<(u16, Value), i32>>> = const { RefCell::new(VecDeque::new()) };
523}
524
525#[cfg(test)]
526fn stripe_test_take_response() -> Option<Result<(u16, Value), i32>> {
527    STRIPE_TEST_RESPONSES.with(|responses| responses.borrow_mut().pop_front())
528}
529
530#[cfg(test)]
531fn stripe_test_set_responses(responses: Vec<Result<(u16, Value), i32>>) {
532    STRIPE_TEST_RESPONSES.with(|queue| {
533        *queue.borrow_mut() = responses.into();
534    });
535}
536
537#[cfg(test)]
538fn stripe_endpoint_url(
539    app_root: &Path,
540    runtime_conf_path: Option<&Path>,
541    config_key: &str,
542    default_url: &str,
543) -> Result<(String, bool), (String, String, i32)> {
544    let source = payment_provider_config_source(app_root, runtime_conf_path);
545    pharos_runtime_payment::stripe_endpoint_url(&source, config_key, default_url)
546}
547
548#[cfg(test)]
549fn stripe_account_status(
550    account: &Value,
551) -> (
552    &'static str,
553    &'static str,
554    bool,
555    bool,
556    bool,
557    Option<String>,
558    Option<String>,
559) {
560    let status = pharos_runtime_payment::stripe_account_status(account);
561    (
562        status.connection_state,
563        status.onboarding_state,
564        status.charges_enabled,
565        status.payouts_enabled,
566        status.details_submitted,
567        status.reason_code,
568        status.reason_detail,
569    )
570}
571
572#[cfg(test)]
573fn payment_provider_state_token(
574    app_root: &Path,
575    runtime_conf_path: Option<&Path>,
576    app_id: &str,
577    tenant_id: i64,
578    owner_resource_id: i64,
579    owner_route_value: &str,
580    provider: &str,
581    actor_user_id: i64,
582    revision: i64,
583    nonce: &str,
584    expires_at: &str,
585) -> Option<String> {
586    let secret = payment_provider_secret_env_value(
587        app_root,
588        runtime_conf_path,
589        "payment_provider_state_key_env",
590    )?;
591    pharos_runtime_payment::payment_provider_state_token(
592        &secret,
593        app_id,
594        tenant_id,
595        owner_resource_id,
596        owner_route_value,
597        provider,
598        actor_user_id,
599        revision,
600        nonce,
601        expires_at,
602    )
603}
604
605#[cfg(test)]
606fn verify_payment_provider_state_token(
607    app_root: &Path,
608    runtime_conf_path: Option<&Path>,
609    presented_state: &str,
610) -> Option<pharos_runtime_payment::VerifiedPaymentProviderStateToken> {
611    let secret = payment_provider_secret_env_value(
612        app_root,
613        runtime_conf_path,
614        "payment_provider_state_key_env",
615    )?;
616    pharos_runtime_payment::verify_payment_provider_state_token(
617        &secret,
618        presented_state,
619        &pharos_runtime_core::time::current_timestamp_utc(),
620    )
621}
622
623pub fn payment_provider_connect_start(
624    root: &mut Value,
625    app_root: &Path,
626    runtime_conf_path: Option<&Path>,
627    owner_route_value: &str,
628    provider: &str,
629    actor_user_id: i64,
630) -> Result<String, i32> {
631    payment_provider_connect_start_with_request(
632        root,
633        app_root,
634        runtime_conf_path,
635        owner_route_value,
636        provider,
637        actor_user_id,
638        PaymentProviderConnectRequest::default(),
639    )
640}
641
642/// Starts provider onboarding from a generic app-submitted connection request.
643pub fn payment_provider_connect_start_with_request(
644    root: &mut Value,
645    app_root: &Path,
646    runtime_conf_path: Option<&Path>,
647    owner_route_value: &str,
648    provider: &str,
649    actor_user_id: i64,
650    request: PaymentProviderConnectRequest<'_>,
651) -> Result<String, i32> {
652    let source = payment_provider_config_source(app_root, runtime_conf_path);
653    pharos_runtime_payment::payment_provider_runtime_connect_start_with_request(
654        root,
655        app_root,
656        &source,
657        owner_route_value,
658        provider,
659        actor_user_id,
660        request,
661    )
662}
663
664pub fn payment_provider_callback_complete(
665    root: &mut Value,
666    app_root: &Path,
667    runtime_conf_path: Option<&Path>,
668    owner_route_value: &str,
669    provider: &str,
670    presented_state: &str,
671    authorization_code: &str,
672    current_actor_user_id: i64,
673) -> Result<(), i32> {
674    let source = payment_provider_config_source(app_root, runtime_conf_path);
675    pharos_runtime_payment::payment_provider_runtime_callback_complete(
676        root,
677        app_root,
678        &source,
679        owner_route_value,
680        provider,
681        presented_state,
682        authorization_code,
683        current_actor_user_id,
684    )
685}
686
687pub fn payment_provider_refresh(
688    root: &mut Value,
689    app_root: &Path,
690    runtime_conf_path: Option<&Path>,
691    owner_route_value: &str,
692    provider: &str,
693    actor_user_id: i64,
694) -> Result<(), i32> {
695    let source = payment_provider_config_source(app_root, runtime_conf_path);
696    pharos_runtime_payment::payment_provider_runtime_refresh(
697        root,
698        app_root,
699        &source,
700        owner_route_value,
701        provider,
702        actor_user_id,
703    )
704}
705
706pub fn payment_provider_disconnect(
707    root: &mut Value,
708    app_root: &Path,
709    runtime_conf_path: Option<&Path>,
710    owner_route_value: &str,
711    provider: &str,
712    actor_user_id: i64,
713) -> Result<(), i32> {
714    let source = payment_provider_config_source(app_root, runtime_conf_path);
715    pharos_runtime_payment::payment_provider_runtime_disconnect(
716        root,
717        app_root,
718        &source,
719        owner_route_value,
720        provider,
721        actor_user_id,
722    )
723}
724
725pub fn payment_provider_configure_credentials(
726    root: &mut Value,
727    app_root: &Path,
728    runtime_conf_path: Option<&Path>,
729    owner_route_value: &str,
730    provider: &str,
731    actor_user_id: i64,
732    submission: PaymentProviderCredentialSubmission,
733) -> Result<(), i32> {
734    let source = payment_provider_config_source(app_root, runtime_conf_path);
735    pharos_runtime_payment::payment_provider_runtime_configure_credentials(
736        root,
737        app_root,
738        &source,
739        owner_route_value,
740        provider,
741        actor_user_id,
742        submission,
743    )
744}
745/// Tests the currently resolved provider credentials without starting hosted onboarding.
746pub fn payment_provider_test_connection(
747    root: &mut Value,
748    app_root: &Path,
749    runtime_conf_path: Option<&Path>,
750    owner_route_value: &str,
751    provider: &str,
752    actor_user_id: i64,
753    request: PaymentProviderConnectRequest<'_>,
754) -> Result<(), (String, String, i32)> {
755    let source = payment_provider_config_source(app_root, runtime_conf_path);
756    pharos_runtime_payment::payment_provider_runtime_test_connection(
757        root,
758        app_root,
759        &source,
760        owner_route_value,
761        provider,
762        actor_user_id,
763        request,
764    )
765}
766pub fn payment_provider_revoke_credentials(
767    root: &mut Value,
768    app_root: &Path,
769    _runtime_conf_path: Option<&Path>,
770    owner_route_value: &str,
771    provider: &str,
772    actor_user_id: i64,
773) -> Result<(), i32> {
774    pharos_runtime_payment::payment_provider_revoke_credentials(
775        root,
776        app_root,
777        owner_route_value,
778        provider,
779        actor_user_id,
780    )
781}
782pub fn payment_provider_set_checkout_mode(
783    root: &mut Value,
784    app_root: &Path,
785    owner_route_value: &str,
786    provider: &str,
787    actor_user_id: i64,
788    checkout_mode: &str,
789) -> Result<(), i32> {
790    pharos_runtime_payment::payment_provider_set_checkout_mode(
791        root,
792        app_root,
793        owner_route_value,
794        provider,
795        actor_user_id,
796        checkout_mode,
797    )
798}
799pub fn payment_provider_checkout_start(
800    root: &mut Value,
801    app_root: &Path,
802    runtime_conf_path: Option<&Path>,
803    provider: &str,
804    request: PaymentProviderCheckoutRequest,
805) -> Result<PaymentProviderCheckoutStart, i32> {
806    let source = payment_provider_config_source(app_root, runtime_conf_path);
807    pharos_runtime_payment::payment_provider_runtime_checkout_start(
808        root, app_root, &source, provider, request,
809    )
810}
811
812#[cfg(test)]
813fn payment_provider_app_scoped_url(
814    app_root: &Path,
815    runtime_conf_path: Option<&Path>,
816    path: &str,
817) -> Option<String> {
818    let source = payment_provider_config_source(app_root, runtime_conf_path);
819    pharos_runtime_payment::payment_provider_app_scoped_url(app_root, &source, path)
820}
821
822pub fn payment_provider_checkout_webhook_complete(
823    root: &mut Value,
824    app_root: &Path,
825    runtime_conf_path: Option<&Path>,
826    provider: &str,
827    headers: &str,
828    body: &[u8],
829) -> Result<(), i32> {
830    let source = payment_provider_config_source(app_root, runtime_conf_path);
831    let now = SystemTime::now()
832        .duration_since(UNIX_EPOCH)
833        .map(|value| value.as_secs())
834        .unwrap_or_default();
835    pharos_runtime_payment::payment_provider_runtime_checkout_webhook_complete(
836        root, app_root, &source, provider, headers, body, now,
837    )
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843    use crate::runtime::json_commands;
844    use std::path::PathBuf;
845    use std::time::{SystemTime, UNIX_EPOCH};
846
847    fn temp_app_root(name: &str) -> PathBuf {
848        let nanos = SystemTime::now()
849            .duration_since(UNIX_EPOCH)
850            .unwrap_or_default()
851            .as_nanos();
852        let dir = std::env::temp_dir().join(format!("pharos-{name}-{nanos}"));
853        fs::create_dir_all(&dir).expect("create temp app root");
854        dir
855    }
856
857    fn write_payment_provider_fixture_app(app_root: &Path) -> PathBuf {
858        fs::write(
859            app_root.join("pharos.app.yml"),
860            r#"app_id: commerce_test
861runtime:
862  base_path: /commerce-test
863payments:
864  provider_management:
865    owner_resource_type: merchant
866    owner_state_section: merchants
867    owner_route_field: slug
868    owner_id_field: id
869    owner_user_id_field: owner_id
870    tenant_state_section: tenants
871    tenant_owner_id_field: source_merchant_id
872    tenant_id_field: id
873    tenant_owner_user_id_field: owner_user_id
874    membership_state_section: memberships
875    membership_user_id_field: user_id
876    membership_tenant_id_field: tenant_id
877    membership_status_field: membership_status
878    owner_path_prefix: /merchants/
879    owner_route_param: merchant_slug
880    settings_return_path: /merchants/:merchant_slug/
881    supported_providers:
882      - provider: stripe
883        connect_action_id: payment_provider_connect_stripe
884        callback_action_id: payment_provider_callback_stripe
885        test_action_id: payment_provider_test_stripe
886        refresh_action_id: payment_provider_refresh_stripe
887        disconnect_action_id: payment_provider_disconnect_stripe
888        credentials_action_id: payment_provider_credentials_stripe
889        credentials_delete_action_id: payment_provider_credentials_delete_stripe
890        checkout_mode_action_id: payment_provider_mode_stripe
891        credential_fields:
892          - name: stripe_client_id
893            secret_kind: client_id
894            required: true
895          - name: stripe_client_secret
896            secret_kind: client_secret
897            required: true
898  provider_checkout:
899    action_id: complete_purchase
900    provider: stripe
901    reference_prefix: ORDER
902    owner_route_value: acme-audio
903    success_path: /checkout/success/
904    cancel_path: /checkout/cancel/
905    webhook_action_id: payment_provider_webhook_stripe
906"#,
907        )
908        .expect("write manifest");
909        fs::create_dir_all(app_root.join("data/contracts"))
910            .expect("create route contract directory");
911        fs::write(
912            app_root.join("data/contracts/routes.yml"),
913            r#"routes:
914  - action_id: payment_provider_callback_stripe
915    path: /merchants/:merchant_slug/payment-providers/stripe/callback/
916"#,
917        )
918        .expect("write route contract");
919        let conf_path = app_root.join("app.conf");
920        fs::write(
921            &conf_path,
922            "base_url=http://127.0.0.1:8084/commerce-test\npayment_providers=stripe\nstripe_client_id=ca_test_123\nstripe_client_secret_env=PATH\nstripe_webhook_secret_env=PATH\npayment_provider_state_key_env=PATH\npayment_provider_secret_key_env=__pharos_test_payment_provider_secret_key__\n",
923        )
924        .expect("write config");
925        conf_path
926    }
927
928    fn write_payment_provider_appointment_fixture_app(app_root: &Path) -> PathBuf {
929        fs::write(
930            app_root.join("pharos.app.yml"),
931            r#"app_id: ucal_test
932runtime:
933  base_path: /ucal
934payments:
935  provider_management:
936    owner_resource_type: business
937    owner_state_section: businesses
938    owner_route_field: slug
939    owner_id_field: id
940    owner_user_id_field: owner_id
941    tenant_state_section: tenants
942    tenant_owner_id_field: source_business_id
943    tenant_id_field: id
944    tenant_owner_user_id_field: owner_user_id
945    owner_path_prefix: /businesses/
946    owner_route_param: business_slug
947    settings_return_path: /businesses/:business_slug/
948    supported_providers:
949      - provider: stripe
950        connect_action_id: payment_provider_connect_stripe
951        callback_action_id: payment_provider_callback_stripe
952        refresh_action_id: payment_provider_refresh_stripe
953        disconnect_action_id: payment_provider_disconnect_stripe
954        credentials_action_id: payment_provider_credentials_stripe
955        credentials_delete_action_id: payment_provider_credentials_delete_stripe
956        checkout_mode_action_id: payment_provider_mode_stripe
957        credential_fields:
958          - name: stripe_client_id
959            secret_kind: client_id
960            required: true
961          - name: stripe_client_secret
962            secret_kind: client_secret
963            required: true
964  provider_checkout:
965    kind: appointment_booking
966    action_id: create_appointment
967    provider: stripe
968    reference_prefix: APPT
969    success_path: /appointments/
970    cancel_path: /explore/
971    webhook_action_id: payment_provider_checkout_webhook_stripe
972"#,
973        )
974        .expect("write appointment manifest");
975        fs::create_dir_all(app_root.join("data/contracts"))
976            .expect("create route contract directory");
977        fs::write(
978            app_root.join("data/contracts/routes.yml"),
979            r#"routes:
980  - action_id: payment_provider_callback_stripe
981    path: /businesses/:business_slug/payment-providers/stripe/callback/
982"#,
983        )
984        .expect("write appointment routes");
985        let conf_path = app_root.join("app.conf");
986        fs::write(
987            &conf_path,
988            "base_url=http://127.0.0.1:8084/ucal\npayment_providers=stripe\nstripe_client_id=ca_test_123\nstripe_client_secret_env=PATH\nstripe_webhook_secret_env=PATH\npayment_provider_state_key_env=PATH\npayment_provider_secret_key_env=__pharos_test_payment_provider_secret_key__\n",
989        )
990        .expect("write appointment config");
991        conf_path
992    }
993
994    #[test]
995    fn payment_provider_copy_value_loads_manifest_declared_provider_file() {
996        let app_root = temp_app_root("payment-provider-file");
997        fs::create_dir_all(app_root.join("data")).expect("create data dir");
998        fs::write(
999            app_root.join("pharos.app.yml"),
1000            r#"app_id: commerce_test
1001payments:
1002  provider_src: data/payment_provider.yml
1003"#,
1004        )
1005        .expect("write manifest");
1006        fs::write(
1007            app_root.join("data/payment_provider.yml"),
1008            r#"owner_resource_type: merchant
1009supported_providers:
1010  - provider: stripe
1011copy:
1012  owner_payment_provider_setup_headline: Provider file headline
1013"#,
1014        )
1015        .expect("write provider file");
1016
1017        let headline =
1018            payment_provider_copy_value(&app_root, "owner_payment_provider_setup_headline")
1019                .expect("load provider copy file value");
1020        assert_eq!(headline, "Provider file headline");
1021        fs::remove_dir_all(app_root).ok();
1022    }
1023
1024    #[test]
1025    fn payment_provider_config_loads_manifest_declared_provider_file() {
1026        let app_root = temp_app_root("payment-provider-config-file");
1027        fs::create_dir_all(app_root.join("data")).expect("create data dir");
1028        fs::write(
1029            app_root.join("pharos.app.yml"),
1030            r#"app_id: commerce_test
1031payments:
1032  provider_src: data/payment_provider.yml
1033"#,
1034        )
1035        .expect("write manifest");
1036        fs::write(
1037            app_root.join("data/payment_provider.yml"),
1038            r#"owner_resource_type: merchant
1039supported_providers:
1040  - provider: stripe
1041copy:
1042  owner_payment_provider_setup_headline: Provider file headline
1043"#,
1044        )
1045        .expect("write provider file");
1046
1047        let config = payment_provider_manifest_config(&app_root).expect("load provider config");
1048        assert_eq!(
1049            config.get("owner_resource_type").and_then(Value::as_str),
1050            Some("merchant")
1051        );
1052        assert!(config.get("supported_providers").is_some());
1053        fs::remove_dir_all(app_root).ok();
1054    }
1055
1056    #[test]
1057    fn payment_provider_app_scoped_url_does_not_duplicate_base_path() {
1058        let app_root = temp_app_root("payment-provider-scoped-url");
1059        let conf_path = write_payment_provider_appointment_fixture_app(&app_root);
1060
1061        assert_eq!(
1062            payment_provider_app_scoped_url(&app_root, Some(&conf_path), "/appointments/"),
1063            Some("http://127.0.0.1:8084/ucal/appointments/".to_owned())
1064        );
1065        assert_eq!(
1066            payment_provider_app_scoped_url(&app_root, Some(&conf_path), "/ucal/appointments/"),
1067            Some("http://127.0.0.1:8084/ucal/appointments/".to_owned())
1068        );
1069
1070        fs::remove_dir_all(app_root).ok();
1071    }
1072
1073    #[test]
1074    fn payment_provider_live_checkout_params_remove_demo_copy_and_decline_action() {
1075        let app_root = temp_app_root("payment-provider-live-checkout-copy");
1076        write_payment_provider_fixture_app(&app_root);
1077        let mut root = seed_payment_provider_root();
1078        seed_connected_payment_provider_account(&mut root);
1079        if let Some(row) =
1080            payment_provider_account_row_mut(&mut root, "commerce_test", "merchant", 7, "stripe")
1081            && let Some(object) = row.as_object_mut()
1082        {
1083            object.insert("checkout_mode".to_owned(), Value::String("live".to_owned()));
1084        }
1085        root.as_object_mut().expect("state object").insert(
1086            "payment_provider_secrets".to_owned(),
1087            serde_json::json!([
1088                {
1089                    "app_id": "commerce_test",
1090                    "tenant_id": 91,
1091                    "owner_resource_type": "merchant",
1092                    "owner_resource_id": 7,
1093                    "provider_id": "stripe",
1094                    "secret_kind": "client_id",
1095                    "ciphertext": "encrypted-client-id",
1096                    "revoked_at": ""
1097                },
1098                {
1099                    "app_id": "commerce_test",
1100                    "tenant_id": 91,
1101                    "owner_resource_type": "merchant",
1102                    "owner_resource_id": 7,
1103                    "provider_id": "stripe",
1104                    "secret_kind": "client_secret",
1105                    "ciphertext": "encrypted-client-secret",
1106                    "revoked_at": ""
1107                }
1108            ]),
1109        );
1110
1111        let params = payment_provider_template_params(&root, &app_root, "acme-audio", "stripe");
1112
1113        assert_eq!(
1114            params
1115                .get("payment_checkout_mode_value")
1116                .and_then(Value::as_str),
1117            Some("live")
1118        );
1119        assert_eq!(
1120            params
1121                .get("payment_checkout_notice_class")
1122                .and_then(Value::as_str),
1123            Some("checkout-notice-hidden")
1124        );
1125        assert_eq!(
1126            params
1127                .get("payment_checkout_decline_class")
1128                .and_then(Value::as_str),
1129            Some("checkout-action-hidden")
1130        );
1131        assert_eq!(
1132            params
1133                .get("checkout_primary_action")
1134                .and_then(Value::as_str),
1135            Some("Purchase")
1136        );
1137        assert_eq!(
1138            params.get("checkout_notice_title").and_then(Value::as_str),
1139            Some("")
1140        );
1141        assert_eq!(
1142            params.get("checkout_notice_body").and_then(Value::as_str),
1143            Some("")
1144        );
1145        fs::remove_dir_all(app_root).expect("remove payment provider live checkout copy root");
1146    }
1147
1148    #[test]
1149    fn payment_provider_live_checkout_params_require_saved_credentials() {
1150        let app_root = temp_app_root("payment-provider-live-checkout-requires-credentials");
1151        write_payment_provider_fixture_app(&app_root);
1152        let mut root = seed_payment_provider_root();
1153        seed_connected_payment_provider_account(&mut root);
1154        if let Some(row) =
1155            payment_provider_account_row_mut(&mut root, "commerce_test", "merchant", 7, "stripe")
1156            && let Some(object) = row.as_object_mut()
1157        {
1158            object.insert("checkout_mode".to_owned(), Value::String("live".to_owned()));
1159        }
1160
1161        let params = payment_provider_template_params(&root, &app_root, "acme-audio", "stripe");
1162
1163        assert_eq!(
1164            params
1165                .get("payment_checkout_mode_value")
1166                .and_then(Value::as_str),
1167            Some("demo")
1168        );
1169        assert_eq!(
1170            params
1171                .get("payment_checkout_notice_class")
1172                .and_then(Value::as_str),
1173            Some("demo-notice")
1174        );
1175        assert_eq!(
1176            params
1177                .get("payment_checkout_decline_class")
1178                .and_then(Value::as_str),
1179            Some("")
1180        );
1181        assert_eq!(
1182            params
1183                .get("payment_provider_credentials_state")
1184                .and_then(Value::as_str),
1185            Some("not_saved")
1186        );
1187        fs::remove_dir_all(app_root)
1188            .expect("remove payment provider live checkout requires credentials root");
1189    }
1190
1191    #[test]
1192    fn payment_provider_declares_generic_test_credentials_operation() {
1193        let manifest = serde_json::json!({
1194            "supported_providers": [
1195                {
1196                    "provider": "stripe",
1197                    "connect_action_id": "payment_provider_connect_stripe",
1198                    "test_action_id": "payment_provider_test_stripe"
1199                }
1200            ]
1201        });
1202
1203        assert_eq!(
1204            declared_operation(&manifest, "payment_provider_test_stripe"),
1205            Some((
1206                "stripe".to_owned(),
1207                PaymentProviderOperation::TestCredentials
1208            ))
1209        );
1210        assert_eq!(
1211            declared_operation(&manifest, "payment_provider_connect_stripe"),
1212            Some(("stripe".to_owned(), PaymentProviderOperation::Connect))
1213        );
1214    }
1215
1216    #[test]
1217    fn payment_provider_copy_value_loads_compiled_json_provider_file_for_yaml_manifest_reference() {
1218        let app_root = temp_app_root("payment-provider-json-fallback");
1219        fs::create_dir_all(app_root.join("data")).expect("create data dir");
1220        fs::write(
1221            app_root.join("pharos.app.json"),
1222            r#"{"app_id":"commerce_test","payments":{"provider_src":"data/payment_provider.yml"}}"#,
1223        )
1224        .expect("write manifest");
1225        fs::write(
1226            app_root.join("data/payment_provider.json"),
1227            r#"{"owner_resource_type":"merchant","supported_providers":[{"provider":"stripe"}],"copy":{"owner_payment_provider_setup_headline":"Compiled provider file headline"}}"#,
1228        )
1229        .expect("write provider file");
1230
1231        let headline =
1232            payment_provider_copy_value(&app_root, "owner_payment_provider_setup_headline")
1233                .expect("load compiled provider copy file value");
1234        assert_eq!(headline, "Compiled provider file headline");
1235        fs::remove_dir_all(app_root).ok();
1236    }
1237
1238    fn seed_payment_provider_root() -> Value {
1239        serde_json::json!({
1240                "merchants": [
1241                    {"id": 7, "slug": "acme-audio", "owner_id": 11, "name": "Acme Audio"}
1242                ],
1243                "tenants": [
1244                    {"id": 91, "source_merchant_id": 7, "owner_user_id": 11}
1245                ],
1246                "memberships": [
1247                    {"user_id": 12, "tenant_id": 91, "membership_status": "active"}
1248                ],
1249            "payment_provider_accounts": [],
1250            "payment_provider_secrets": [],
1251            "payment_provider_audit_events": [],
1252            "payment_provider_callback_nonces": []
1253        })
1254    }
1255
1256    fn seed_payment_provider_checkout_cart(root: &mut Value) {
1257        root.as_object_mut().expect("state object").insert(
1258            "shopping_carts".to_owned(),
1259            serde_json::json!([
1260                {"id": 31, "user_id": 12, "currency": "USD"}
1261            ]),
1262        );
1263        root.as_object_mut().expect("state object").insert(
1264            "shopping_cart_items".to_owned(),
1265            serde_json::json!([
1266                {
1267                    "id": 41,
1268                    "cart_id": 31,
1269                    "user_id": 12,
1270                    "product_id": 3,
1271                    "product_slug": "tape-drum-loops",
1272                    "product_title": "Tape Drum Loops",
1273                    "price_minor": 2900,
1274                    "price_display": "$29",
1275                    "currency": "USD",
1276                    "download_asset_key": "tape-drum-loops.zip"
1277                }
1278            ]),
1279        );
1280        root.as_object_mut()
1281            .expect("state object")
1282            .insert("commerce_orders".to_owned(), Value::Array(Vec::new()));
1283        root.as_object_mut()
1284            .expect("state object")
1285            .insert("commerce_order_items".to_owned(), Value::Array(Vec::new()));
1286        root.as_object_mut()
1287            .expect("state object")
1288            .insert("download_entitlements".to_owned(), Value::Array(Vec::new()));
1289        root.as_object_mut().expect("state object").insert(
1290            "inventory_reservations".to_owned(),
1291            serde_json::json!([
1292                {
1293                    "id": 51,
1294                    "cart_id": 31,
1295                    "user_id": 12,
1296                    "product_id": 3,
1297                    "reservation_status": "reserved",
1298                    "reserved_at": "2026-09-10T00:00:00Z",
1299                    "confirmed_at": null,
1300                    "released_at": null,
1301                    "release_reason": ""
1302                }
1303            ]),
1304        );
1305    }
1306
1307    fn stripe_test_signature_header(payload: &str) -> String {
1308        let secret = std::env::var("PATH").expect("PATH is available as test secret");
1309        let timestamp = SystemTime::now()
1310            .duration_since(UNIX_EPOCH)
1311            .unwrap_or_default()
1312            .as_secs()
1313            .to_string();
1314        let message = format!("{timestamp}.{payload}");
1315        let digest = crate::runtime::dynamic::hex_digest(&crate::runtime::dynamic::hmac_sha256(
1316            secret.as_bytes(),
1317            message.as_bytes(),
1318        ));
1319        format!("Stripe-Signature: t={timestamp},v1={digest}")
1320    }
1321
1322    #[test]
1323    fn provider_checkout_redirects_to_stripe_and_unlocks_only_after_signed_webhook() {
1324        let app_root = temp_app_root("payment-provider-checkout");
1325        let conf_path = write_payment_provider_fixture_app(&app_root);
1326        let mut root = seed_payment_provider_root();
1327        seed_payment_provider_checkout_cart(&mut root);
1328        payment_provider_configure_credentials(
1329            &mut root,
1330            &app_root,
1331            Some(conf_path.as_path()),
1332            "acme-audio",
1333            "stripe",
1334            11,
1335            PaymentProviderCredentialSubmission {
1336                fields: vec![
1337                    ("stripe_client_id".to_owned(), "acct_owner_123".to_owned()),
1338                    (
1339                        "stripe_client_secret".to_owned(),
1340                        "sk_test_owner_secret".to_owned(),
1341                    ),
1342                ],
1343            },
1344        )
1345        .expect("configure checkout credentials");
1346        stripe_test_set_responses(vec![Ok((
1347            200,
1348            serde_json::json!({
1349                "id": "approved",
1350                "url": "https://checkout.stripe.test/session/approved"
1351            }),
1352        ))]);
1353        let checkout = payment_provider_checkout_start(
1354            &mut root,
1355            &app_root,
1356            Some(conf_path.as_path()),
1357            "stripe",
1358            PaymentProviderCheckoutRequest {
1359                actor_user_id: 12,
1360                idempotency_key: "checkout-idempotency-001".to_owned(),
1361                request_target: "/checkout".to_owned(),
1362                form_fields: BTreeMap::new(),
1363            },
1364        )
1365        .expect("start live checkout");
1366        assert_eq!(checkout.outcome, PaymentProviderCheckoutOutcome::Redirect);
1367        assert_eq!(
1368            checkout.redirect_location.as_deref(),
1369            Some("https://checkout.stripe.test/session/approved")
1370        );
1371        assert_eq!(root["commerce_orders"][0]["payment_mode"], "live");
1372        assert_eq!(root["commerce_orders"][0]["provider_id"], "stripe");
1373        assert_eq!(
1374            root["commerce_orders"][0]["provider_order_reference"],
1375            "approved"
1376        );
1377        assert!(
1378            root["commerce_orders"][0]["provider_client_reference"]
1379                .as_str()
1380                .is_some_and(|value| value.starts_with("ORDER-") && value.ends_with("-1"))
1381        );
1382        assert_eq!(root["commerce_orders"][0]["status"], "payment_pending");
1383        assert_eq!(
1384            root["download_entitlements"]
1385                .as_array()
1386                .expect("entitlements array")
1387                .len(),
1388            0
1389        );
1390
1391        let payload = r#"{"type":"checkout.session.completed","data":{"object":{"id":"approved","status":"complete","payment_status":"paid"}}}"#;
1392        let headers = stripe_test_signature_header(payload);
1393        payment_provider_checkout_webhook_complete(
1394            &mut root,
1395            &app_root,
1396            Some(conf_path.as_path()),
1397            "stripe",
1398            &headers,
1399            payload.as_bytes(),
1400        )
1401        .expect("complete checkout webhook");
1402        assert_eq!(root["commerce_orders"][0]["status"], "paid");
1403        assert_eq!(root["download_entitlements"][0]["user_id"], 12);
1404        assert_eq!(
1405            root["inventory_reservations"][0]["reservation_status"],
1406            "confirmed"
1407        );
1408        assert_eq!(
1409            root["shopping_cart_items"]
1410                .as_array()
1411                .expect("cart items")
1412                .len(),
1413            0
1414        );
1415        fs::remove_dir_all(app_root).ok();
1416    }
1417
1418    #[test]
1419    fn provider_checkout_terminal_webhooks_keep_or_revoke_access_correctly() {
1420        let app_root = temp_app_root("payment-provider-checkout-terminal-webhooks");
1421        let conf_path = write_payment_provider_fixture_app(&app_root);
1422        let mut root = seed_payment_provider_root();
1423        seed_payment_provider_checkout_cart(&mut root);
1424        payment_provider_configure_credentials(
1425            &mut root,
1426            &app_root,
1427            Some(conf_path.as_path()),
1428            "acme-audio",
1429            "stripe",
1430            11,
1431            PaymentProviderCredentialSubmission {
1432                fields: vec![
1433                    ("stripe_client_id".to_owned(), "acct_owner_123".to_owned()),
1434                    (
1435                        "stripe_client_secret".to_owned(),
1436                        "sk_test_owner_secret".to_owned(),
1437                    ),
1438                ],
1439            },
1440        )
1441        .expect("configure checkout credentials");
1442        stripe_test_set_responses(vec![Ok((
1443            200,
1444            serde_json::json!({
1445                "id": "cs_expired_session",
1446                "url": "https://checkout.stripe.test/session/cs_expired_session"
1447            }),
1448        ))]);
1449        payment_provider_checkout_start(
1450            &mut root,
1451            &app_root,
1452            Some(conf_path.as_path()),
1453            "stripe",
1454            PaymentProviderCheckoutRequest {
1455                actor_user_id: 12,
1456                idempotency_key: "checkout-idempotency-expired".to_owned(),
1457                request_target: "/checkout".to_owned(),
1458                form_fields: BTreeMap::new(),
1459            },
1460        )
1461        .expect("start expired checkout");
1462        let expired_payload = r#"{"type":"checkout.session.expired","data":{"object":{"id":"cs_expired_session","status":"expired","payment_status":"unpaid"}}}"#;
1463        let expired_headers = stripe_test_signature_header(expired_payload);
1464        payment_provider_checkout_webhook_complete(
1465            &mut root,
1466            &app_root,
1467            Some(conf_path.as_path()),
1468            "stripe",
1469            &expired_headers,
1470            expired_payload.as_bytes(),
1471        )
1472        .expect("expire checkout");
1473        assert_eq!(root["commerce_orders"][0]["status"], "payment_expired");
1474        assert_eq!(
1475            root["download_entitlements"]
1476                .as_array()
1477                .expect("entitlements")
1478                .len(),
1479            0
1480        );
1481
1482        let mut paid_root = seed_payment_provider_root();
1483        seed_payment_provider_checkout_cart(&mut paid_root);
1484        payment_provider_configure_credentials(
1485            &mut paid_root,
1486            &app_root,
1487            Some(conf_path.as_path()),
1488            "acme-audio",
1489            "stripe",
1490            11,
1491            PaymentProviderCredentialSubmission {
1492                fields: vec![
1493                    ("stripe_client_id".to_owned(), "acct_owner_123".to_owned()),
1494                    (
1495                        "stripe_client_secret".to_owned(),
1496                        "sk_test_owner_secret".to_owned(),
1497                    ),
1498                ],
1499            },
1500        )
1501        .expect("configure refund checkout credentials");
1502        stripe_test_set_responses(vec![Ok((
1503            200,
1504            serde_json::json!({
1505                "id": "cs_refunded_session",
1506                "url": "https://checkout.stripe.test/session/cs_refunded_session"
1507            }),
1508        ))]);
1509        payment_provider_checkout_start(
1510            &mut paid_root,
1511            &app_root,
1512            Some(conf_path.as_path()),
1513            "stripe",
1514            PaymentProviderCheckoutRequest {
1515                actor_user_id: 12,
1516                idempotency_key: "checkout-idempotency-refund".to_owned(),
1517                request_target: "/checkout".to_owned(),
1518                form_fields: BTreeMap::new(),
1519            },
1520        )
1521        .expect("start refundable checkout");
1522        let paid_payload = r#"{"type":"checkout.session.completed","data":{"object":{"id":"cs_refunded_session","status":"complete","payment_status":"paid"}}}"#;
1523        let paid_headers = stripe_test_signature_header(paid_payload);
1524        payment_provider_checkout_webhook_complete(
1525            &mut paid_root,
1526            &app_root,
1527            Some(conf_path.as_path()),
1528            "stripe",
1529            &paid_headers,
1530            paid_payload.as_bytes(),
1531        )
1532        .expect("pay checkout");
1533        assert_eq!(paid_root["commerce_orders"][0]["status"], "paid");
1534        assert_eq!(
1535            paid_root["download_entitlements"]
1536                .as_array()
1537                .expect("entitlements")
1538                .len(),
1539            1
1540        );
1541        let refund_payload = r#"{"type":"charge.refunded","data":{"object":{"id":"ch_123","metadata":{"checkout_session_id":"cs_refunded_session"}}}}"#;
1542        let refund_headers = stripe_test_signature_header(refund_payload);
1543        payment_provider_checkout_webhook_complete(
1544            &mut paid_root,
1545            &app_root,
1546            Some(conf_path.as_path()),
1547            "stripe",
1548            &refund_headers,
1549            refund_payload.as_bytes(),
1550        )
1551        .expect("refund checkout");
1552        assert_eq!(paid_root["commerce_orders"][0]["status"], "refunded");
1553        assert_eq!(
1554            paid_root["download_entitlements"]
1555                .as_array()
1556                .expect("entitlements")
1557                .len(),
1558            0
1559        );
1560        fs::remove_dir_all(app_root).ok();
1561    }
1562
1563    #[test]
1564    fn provider_checkout_falls_back_to_demo_when_credentials_are_not_saved() {
1565        let app_root = temp_app_root("payment-provider-checkout-demo-fallback");
1566        let conf_path = write_payment_provider_fixture_app(&app_root);
1567        let mut root = seed_payment_provider_root();
1568        seed_payment_provider_checkout_cart(&mut root);
1569        let checkout = payment_provider_checkout_start(
1570            &mut root,
1571            &app_root,
1572            Some(conf_path.as_path()),
1573            "stripe",
1574            PaymentProviderCheckoutRequest {
1575                actor_user_id: 12,
1576                idempotency_key: "checkout-idempotency-002".to_owned(),
1577                request_target: "/checkout".to_owned(),
1578                form_fields: BTreeMap::new(),
1579            },
1580        )
1581        .expect("demo fallback checkout");
1582        assert_eq!(
1583            checkout.outcome,
1584            PaymentProviderCheckoutOutcome::DemoFallback
1585        );
1586        assert!(
1587            root["commerce_orders"]
1588                .as_array()
1589                .expect("orders")
1590                .is_empty()
1591        );
1592        fs::remove_dir_all(app_root).ok();
1593    }
1594
1595    #[test]
1596    fn provider_checkout_supports_prepaid_appointment_confirmation() {
1597        let app_root = temp_app_root("payment-provider-appointment-checkout");
1598        let conf_path = write_payment_provider_appointment_fixture_app(&app_root);
1599        let mut root = serde_json::json!({
1600            "businesses": [
1601                {"id": 1, "slug": "lotus-studio", "owner_id": 11, "name": "Lotus Studio"}
1602            ],
1603            "tenants": [
1604                {"id": 91, "source_business_id": 1, "owner_user_id": 11}
1605            ],
1606            "services": [
1607                {
1608                    "id": 4,
1609                    "business_id": 1,
1610                    "name": "Studio session",
1611                    "duration_minutes": 60,
1612                    "cost": "75",
1613                    "is_active": true,
1614                    "technician_ids": [21]
1615                }
1616            ],
1617            "technicians": [
1618                {"id": 21, "business_id": 1, "name": "Ari", "is_active": true}
1619            ],
1620            "business_availability": [
1621                {"id": 31, "business_id": 1, "day_of_week": "Tue", "start_time": "09:00", "end_time": "17:00", "is_active": true}
1622            ],
1623            "technician_availability": [
1624                {"id": 41, "technician_id": 21, "day_of_week": "Tue", "start_time": "09:00", "end_time": "17:00", "is_active": true}
1625            ],
1626            "appointments": [],
1627            "payment_provider_accounts": [],
1628            "payment_provider_secrets": [],
1629            "payment_provider_audit_events": [],
1630            "payment_provider_callback_nonces": []
1631        });
1632        payment_provider_configure_credentials(
1633            &mut root,
1634            &app_root,
1635            Some(conf_path.as_path()),
1636            "lotus-studio",
1637            "stripe",
1638            11,
1639            PaymentProviderCredentialSubmission {
1640                fields: vec![
1641                    ("stripe_client_id".to_owned(), "ca_owner_123".to_owned()),
1642                    (
1643                        "stripe_client_secret".to_owned(),
1644                        "sk_test_owner_secret".to_owned(),
1645                    ),
1646                ],
1647            },
1648        )
1649        .expect("configure appointment credentials");
1650        stripe_test_set_responses(vec![Ok((
1651            200,
1652            serde_json::json!({
1653                "id": "approved",
1654                "url": "https://checkout.stripe.test/session/appointment-approved"
1655            }),
1656        ))]);
1657        let checkout = payment_provider_checkout_start(
1658            &mut root,
1659            &app_root,
1660            Some(conf_path.as_path()),
1661            "stripe",
1662            PaymentProviderCheckoutRequest {
1663                actor_user_id: 12,
1664                idempotency_key: "appointment-idempotency-001".to_owned(),
1665                request_target: "/business/lotus-studio/book/4/".to_owned(),
1666                form_fields: BTreeMap::from_iter([
1667                    ("date".to_owned(), "2026-09-15".to_owned()),
1668                    ("time_hour".to_owned(), "2".to_owned()),
1669                    ("time_minute".to_owned(), "30".to_owned()),
1670                    ("time_period".to_owned(), "PM".to_owned()),
1671                    ("notes".to_owned(), "Prepaid test".to_owned()),
1672                ]),
1673            },
1674        )
1675        .expect("start appointment checkout");
1676        assert_eq!(checkout.outcome, PaymentProviderCheckoutOutcome::Redirect);
1677        assert_eq!(
1678            checkout.redirect_location.as_deref(),
1679            Some("https://checkout.stripe.test/session/appointment-approved")
1680        );
1681        assert_eq!(root["appointments"][0]["status"], "payment_pending");
1682        assert_eq!(root["appointments"][0]["payment_status"], "pending");
1683        assert_eq!(root["appointments"][0]["payment_mode"], "live");
1684        assert_eq!(root["appointments"][0]["provider_id"], "stripe");
1685        assert_eq!(
1686            root["appointments"][0]["provider_order_reference"],
1687            "approved"
1688        );
1689        assert!(
1690            root["appointments"][0]["provider_client_reference"]
1691                .as_str()
1692                .is_some_and(|value| value.starts_with("APPT-") && value.ends_with("-1"))
1693        );
1694
1695        let payload = r#"{"type":"checkout.session.completed","data":{"object":{"id":"approved","status":"complete","payment_status":"paid"}}}"#;
1696        let headers = stripe_test_signature_header(payload);
1697        payment_provider_checkout_webhook_complete(
1698            &mut root,
1699            &app_root,
1700            Some(conf_path.as_path()),
1701            "stripe",
1702            &headers,
1703            payload.as_bytes(),
1704        )
1705        .expect("complete appointment webhook");
1706        assert_eq!(root["appointments"][0]["status"], "confirmed");
1707        assert_eq!(root["appointments"][0]["payment_status"], "paid");
1708        fs::remove_dir_all(app_root).ok();
1709    }
1710
1711    #[test]
1712    fn provider_checkout_terminal_webhooks_update_prepaid_appointments() {
1713        let app_root = temp_app_root("payment-provider-appointment-terminal-webhooks");
1714        let conf_path = write_payment_provider_appointment_fixture_app(&app_root);
1715        let mut root = serde_json::json!({
1716            "businesses": [
1717                {"id": 1, "slug": "lotus-studio", "owner_id": 11, "name": "Lotus Studio"}
1718            ],
1719            "tenants": [
1720                {"id": 91, "source_business_id": 1, "owner_user_id": 11}
1721            ],
1722            "services": [
1723                {
1724                    "id": 4,
1725                    "business_id": 1,
1726                    "name": "Studio session",
1727                    "duration_minutes": 60,
1728                    "cost": "75",
1729                    "is_active": true,
1730                    "technician_ids": [21]
1731                }
1732            ],
1733            "technicians": [
1734                {"id": 21, "business_id": 1, "name": "Ari", "is_active": true}
1735            ],
1736            "business_availability": [
1737                {"id": 31, "business_id": 1, "day_of_week": "Tue", "start_time": "09:00", "end_time": "17:00", "is_active": true}
1738            ],
1739            "technician_availability": [
1740                {"id": 41, "technician_id": 21, "day_of_week": "Tue", "start_time": "09:00", "end_time": "17:00", "is_active": true}
1741            ],
1742            "appointments": [],
1743            "payment_provider_accounts": [],
1744            "payment_provider_secrets": [],
1745            "payment_provider_audit_events": [],
1746            "payment_provider_callback_nonces": []
1747        });
1748        payment_provider_configure_credentials(
1749            &mut root,
1750            &app_root,
1751            Some(conf_path.as_path()),
1752            "lotus-studio",
1753            "stripe",
1754            11,
1755            PaymentProviderCredentialSubmission {
1756                fields: vec![
1757                    ("stripe_client_id".to_owned(), "ca_owner_123".to_owned()),
1758                    (
1759                        "stripe_client_secret".to_owned(),
1760                        "sk_test_owner_secret".to_owned(),
1761                    ),
1762                ],
1763            },
1764        )
1765        .expect("configure appointment credentials");
1766        stripe_test_set_responses(vec![Ok((
1767            200,
1768            serde_json::json!({
1769                "id": "cs_failed_appointment_session",
1770                "url": "https://checkout.stripe.test/session/cs_failed_appointment_session"
1771            }),
1772        ))]);
1773        payment_provider_checkout_start(
1774            &mut root,
1775            &app_root,
1776            Some(conf_path.as_path()),
1777            "stripe",
1778            PaymentProviderCheckoutRequest {
1779                actor_user_id: 12,
1780                idempotency_key: "appointment-idempotency-failed".to_owned(),
1781                request_target: "/business/lotus-studio/book/4/".to_owned(),
1782                form_fields: BTreeMap::from_iter([
1783                    ("date".to_owned(), "2026-09-15".to_owned()),
1784                    ("time_hour".to_owned(), "2".to_owned()),
1785                    ("time_minute".to_owned(), "30".to_owned()),
1786                    ("time_period".to_owned(), "PM".to_owned()),
1787                ]),
1788            },
1789        )
1790        .expect("start failed appointment checkout");
1791        let failed_payload = r#"{"type":"payment_intent.payment_failed","data":{"object":{"id":"pi_123","metadata":{"checkout_session_id":"cs_failed_appointment_session"}}}}"#;
1792        let failed_headers = stripe_test_signature_header(failed_payload);
1793        payment_provider_checkout_webhook_complete(
1794            &mut root,
1795            &app_root,
1796            Some(conf_path.as_path()),
1797            "stripe",
1798            &failed_headers,
1799            failed_payload.as_bytes(),
1800        )
1801        .expect("fail appointment checkout");
1802        assert_eq!(root["appointments"][0]["status"], "payment_failed");
1803        assert_eq!(root["appointments"][0]["payment_status"], "failed");
1804
1805        let dispute_payload = r#"{"type":"charge.dispute.created","data":{"object":{"id":"dp_123","metadata":{"checkout_session_id":"cs_failed_appointment_session"}}}}"#;
1806        let dispute_headers = stripe_test_signature_header(dispute_payload);
1807        payment_provider_checkout_webhook_complete(
1808            &mut root,
1809            &app_root,
1810            Some(conf_path.as_path()),
1811            "stripe",
1812            &dispute_headers,
1813            dispute_payload.as_bytes(),
1814        )
1815        .expect("dispute appointment checkout");
1816        assert_eq!(root["appointments"][0]["status"], "payment_disputed");
1817        assert_eq!(root["appointments"][0]["payment_status"], "disputed");
1818        fs::remove_dir_all(app_root).ok();
1819    }
1820
1821    #[test]
1822    fn payment_provider_owner_resolver_supports_non_business_owner_without_runtime_branch() {
1823        let manifest = serde_json::json!({
1824            "owner_resource_type": "organization",
1825            "owner_state_section": "organizations",
1826            "owner_route_field": "handle",
1827            "owner_id_field": "org_id",
1828            "owner_user_id_field": "admin_user_id",
1829            "tenant_state_section": "accounts",
1830            "tenant_owner_id_field": "source_org_id",
1831            "tenant_id_field": "account_id",
1832            "tenant_owner_user_id_field": "primary_user_id",
1833            "membership_state_section": "account_memberships",
1834            "membership_user_id_field": "user_id",
1835            "membership_tenant_id_field": "account_id",
1836            "membership_status_field": "status",
1837        });
1838        let root = serde_json::json!({
1839            "organizations": [
1840                {"org_id": 42, "handle": "northstar-labs", "admin_user_id": 501}
1841            ],
1842            "accounts": [
1843                {"account_id": 7001, "source_org_id": 42, "primary_user_id": 501}
1844            ],
1845            "account_memberships": [
1846                {"account_id": 7001, "user_id": 502, "status": "active"},
1847                {"account_id": 7001, "user_id": 503, "status": "suspended"}
1848            ]
1849        });
1850        let owner = resolve_manifest_owner(&manifest, &root, "northstar-labs")
1851            .expect("resolve non-business owner");
1852        assert_eq!(
1853            owner,
1854            PaymentProviderOwner {
1855                owner_type: "organization".to_owned(),
1856                owner_id: 42,
1857                tenant_id: 7001,
1858                route_value: "northstar-labs".to_owned(),
1859            }
1860        );
1861        let resolver = ManifestOwnerResolver::from_manifest(&manifest).expect("owner resolver");
1862        assert!(resolver.actor_owns(&root, &owner, 501));
1863        assert!(resolver.actor_owns(&root, &owner, 502));
1864        assert!(!resolver.actor_owns(&root, &owner, 503));
1865        assert!(!resolver.actor_owns(&root, &owner, 999));
1866    }
1867
1868    #[test]
1869    fn payment_provider_owner_route_value_accepts_mounted_runtime_path() {
1870        let app_root = temp_app_root("payment-provider-mounted-path");
1871        write_payment_provider_fixture_app(&app_root);
1872        assert_eq!(
1873            payment_provider_owner_route_value(
1874                &app_root,
1875                "/commerce-test/merchants/acme-audio/payment-providers/stripe/connect/"
1876            )
1877            .as_deref(),
1878            Some("acme-audio")
1879        );
1880        assert_eq!(
1881            payment_provider_owner_route_value(
1882                &app_root,
1883                "/merchants/acme-audio/payment-providers/stripe/connect/"
1884            )
1885            .as_deref(),
1886            Some("acme-audio")
1887        );
1888        fs::remove_dir_all(app_root).expect("remove payment provider mounted-path root");
1889    }
1890
1891    #[test]
1892    fn payment_provider_actor_ownership_uses_manifest_declared_fields() {
1893        let app_root = temp_app_root("payment-provider-actor-ownership");
1894        write_payment_provider_fixture_app(&app_root);
1895        let root = seed_payment_provider_root();
1896        assert_eq!(
1897            actor_owns_declared_payment_provider_route(
1898                &root,
1899                &app_root,
1900                "/commerce-test/merchants/acme-audio/payment-providers/stripe/connect/",
1901                11,
1902            ),
1903            Ok(true)
1904        );
1905        assert_eq!(
1906            actor_owns_declared_payment_provider_route(
1907                &root,
1908                &app_root,
1909                "/commerce-test/merchants/acme-audio/payment-providers/stripe/connect/",
1910                12,
1911            ),
1912            Ok(true)
1913        );
1914        assert_eq!(
1915            actor_owns_declared_payment_provider_route(
1916                &root,
1917                &app_root,
1918                "/commerce-test/merchants/acme-audio/payment-providers/stripe/connect/",
1919                99,
1920            ),
1921            Ok(false)
1922        );
1923        fs::remove_dir_all(app_root).expect("remove payment provider actor-ownership root");
1924    }
1925
1926    #[test]
1927    fn payment_provider_stripe_endpoint_overrides_require_https_or_explicit_loopback_http() {
1928        let app_root = temp_app_root("payment-provider-endpoint-policy");
1929        let conf_path = write_payment_provider_fixture_app(&app_root);
1930        fs::write(
1931            &conf_path,
1932            "base_url=http://127.0.0.1:8084/commerce-test\npayment_providers=stripe\nstripe_client_id=ca_test_123\nstripe_client_secret_env=PATH\npayment_provider_state_key_env=PATH\npayment_provider_secret_key_env=__pharos_test_payment_provider_secret_key__\nstripe_oauth_token_url=http://192.0.2.10/oauth/token\n",
1933        )
1934        .expect("write unsafe endpoint config");
1935        assert_eq!(
1936            stripe_endpoint_url(
1937                &app_root,
1938                Some(conf_path.as_path()),
1939                "stripe_oauth_token_url",
1940                "https://connect.stripe.com/oauth/token",
1941            )
1942            .expect_err("reject non-loopback http")
1943            .2,
1944            503
1945        );
1946        fs::write(
1947            &conf_path,
1948            "base_url=http://127.0.0.1:8084/commerce-test\npayment_providers=stripe\nstripe_client_id=ca_test_123\nstripe_client_secret_env=PATH\npayment_provider_state_key_env=PATH\npayment_provider_secret_key_env=__pharos_test_payment_provider_secret_key__\npayment_provider_allow_plaintext_local=true\nstripe_oauth_token_url=http://127.0.0.1:19999/oauth/token\n",
1949        )
1950        .expect("write loopback endpoint config");
1951        assert_eq!(
1952            stripe_endpoint_url(
1953                &app_root,
1954                Some(conf_path.as_path()),
1955                "stripe_oauth_token_url",
1956                "https://connect.stripe.com/oauth/token",
1957            )
1958            .expect("accept explicit loopback http"),
1959            ("http://127.0.0.1:19999/oauth/token".to_owned(), true)
1960        );
1961        fs::remove_dir_all(app_root).expect("remove payment provider endpoint-policy root");
1962    }
1963
1964    #[test]
1965    fn payment_provider_secret_storage_encrypts_and_binds_owner_metadata() {
1966        let app_root = temp_app_root("payment-provider-secret");
1967        let conf_path = write_payment_provider_fixture_app(&app_root);
1968        let mut root = seed_payment_provider_root();
1969        let secret_id = upsert_payment_provider_secret(
1970            &mut root,
1971            &app_root,
1972            Some(conf_path.as_path()),
1973            "commerce_test",
1974            91,
1975            "merchant",
1976            7,
1977            "stripe",
1978            "refresh_token",
1979            "rt_sensitive_value",
1980        )
1981        .expect("encrypt secret");
1982        let row = root["payment_provider_secrets"]
1983            .as_array()
1984            .and_then(|rows| rows.first())
1985            .expect("secret row");
1986        assert_eq!(row["id"].as_i64(), Some(secret_id));
1987        let ciphertext = row["ciphertext"].as_str().expect("ciphertext");
1988        assert!(!ciphertext.contains("rt_sensitive_value"));
1989        let aad = pharos_runtime_payment::payment_provider_secret_aad(
1990            "commerce_test",
1991            91,
1992            "merchant",
1993            7,
1994            "stripe",
1995            "refresh_token",
1996        )
1997        .expect("aad");
1998        let key =
1999            payment_provider_encryption_key_for_version(&app_root, Some(conf_path.as_path()), "v1")
2000                .expect("secret key");
2001        assert_eq!(
2002            pharos_runtime_payment::decrypt_payment_provider_secret_with_key(
2003                &key, &aad, ciphertext,
2004            )
2005            .as_deref(),
2006            Some("rt_sensitive_value")
2007        );
2008        let wrong_owner_aad = pharos_runtime_payment::payment_provider_secret_aad(
2009            "commerce_test",
2010            91,
2011            "merchant",
2012            8,
2013            "stripe",
2014            "refresh_token",
2015        )
2016        .expect("wrong owner aad");
2017        assert!(
2018            pharos_runtime_payment::decrypt_payment_provider_secret_with_key(
2019                &key,
2020                &wrong_owner_aad,
2021                ciphertext,
2022            )
2023            .is_none()
2024        );
2025        fs::remove_dir_all(app_root).expect("remove payment provider secret root");
2026    }
2027
2028    #[test]
2029    fn payment_provider_secret_rotation_and_revocation_are_fail_closed() {
2030        let app_root = temp_app_root("payment-provider-secret-rotation");
2031        let conf_path = write_payment_provider_fixture_app(&app_root);
2032        let mut root = seed_payment_provider_root();
2033        let secret_id = upsert_payment_provider_secret(
2034            &mut root,
2035            &app_root,
2036            Some(conf_path.as_path()),
2037            "commerce_test",
2038            91,
2039            "merchant",
2040            7,
2041            "stripe",
2042            "refresh_token",
2043            "rt_rotate_me",
2044        )
2045        .expect("write v1 secret");
2046        fs::write(
2047            &conf_path,
2048            "base_url=http://127.0.0.1:8084/commerce-test\npayment_providers=stripe\nstripe_client_id=ca_test_123\nstripe_client_secret_env=PATH\npayment_provider_state_key_env=PATH\npayment_provider_secret_key_version=v2\npayment_provider_secret_key_env=__pharos_test_payment_provider_secret_key_v2__\npayment_provider_secret_previous_key_version=v1\npayment_provider_secret_previous_key_env=__pharos_test_payment_provider_secret_key__\n",
2049        )
2050        .expect("write rotated config");
2051        assert_eq!(
2052            read_payment_provider_secret(
2053                &root,
2054                &app_root,
2055                Some(conf_path.as_path()),
2056                "commerce_test",
2057                91,
2058                "merchant",
2059                7,
2060                "stripe",
2061                "refresh_token",
2062            )
2063            .as_deref(),
2064            Ok("rt_rotate_me")
2065        );
2066        assert_eq!(
2067            rotate_payment_provider_secret(
2068                &mut root,
2069                &app_root,
2070                Some(conf_path.as_path()),
2071                "commerce_test",
2072                91,
2073                "merchant",
2074                7,
2075                "stripe",
2076                "refresh_token",
2077            ),
2078            Ok(secret_id)
2079        );
2080        assert_eq!(root["payment_provider_secrets"][0]["key_version"], "v2");
2081        revoke_payment_provider_secret(
2082            &mut root,
2083            "commerce_test",
2084            91,
2085            "merchant",
2086            7,
2087            "stripe",
2088            "refresh_token",
2089        )
2090        .expect("revoke secret");
2091        assert_eq!(root["payment_provider_secrets"][0]["ciphertext"], "");
2092        assert_eq!(
2093            read_payment_provider_secret(
2094                &root,
2095                &app_root,
2096                Some(conf_path.as_path()),
2097                "commerce_test",
2098                91,
2099                "merchant",
2100                7,
2101                "stripe",
2102                "refresh_token",
2103            ),
2104            Err(409)
2105        );
2106        fs::remove_dir_all(app_root).expect("remove payment provider secret rotation root");
2107    }
2108
2109    #[test]
2110    fn payment_provider_credentials_action_encrypts_declared_owner_fields() {
2111        let app_root = temp_app_root("payment-provider-credentials");
2112        let conf_path = write_payment_provider_fixture_app(&app_root);
2113        let mut root = seed_payment_provider_root();
2114
2115        payment_provider_configure_credentials(
2116            &mut root,
2117            &app_root,
2118            Some(conf_path.as_path()),
2119            "acme-audio",
2120            "stripe",
2121            11,
2122            PaymentProviderCredentialSubmission {
2123                fields: vec![
2124                    ("stripe_client_id".to_owned(), "acct_owner_123".to_owned()),
2125                    (
2126                        "stripe_client_secret".to_owned(),
2127                        "sk_test_owner_secret".to_owned(),
2128                    ),
2129                ],
2130            },
2131        )
2132        .expect("configure credentials");
2133
2134        let secrets = root["payment_provider_secrets"]
2135            .as_array()
2136            .expect("secret rows");
2137        assert_eq!(secrets.len(), 2);
2138        assert!(secrets.iter().all(|row| {
2139            row.get("ciphertext")
2140                .and_then(Value::as_str)
2141                .is_some_and(|ciphertext| {
2142                    !ciphertext.contains("acct_owner_123")
2143                        && !ciphertext.contains("sk_test_owner_secret")
2144                })
2145        }));
2146        assert_eq!(
2147            read_payment_provider_secret(
2148                &root,
2149                &app_root,
2150                Some(conf_path.as_path()),
2151                "commerce_test",
2152                91,
2153                "merchant",
2154                7,
2155                "stripe",
2156                "client_id",
2157            )
2158            .as_deref(),
2159            Ok("acct_owner_123")
2160        );
2161        assert_eq!(
2162            read_payment_provider_secret(
2163                &root,
2164                &app_root,
2165                Some(conf_path.as_path()),
2166                "commerce_test",
2167                91,
2168                "merchant",
2169                7,
2170                "stripe",
2171                "client_secret",
2172            )
2173            .as_deref(),
2174            Ok("sk_test_owner_secret")
2175        );
2176        let audit_json =
2177            serde_json::to_string(&root["payment_provider_audit_events"]).expect("audit json");
2178        assert!(audit_json.contains("provider_credentials_configured"));
2179        assert!(!audit_json.contains("sk_test_owner_secret"));
2180        assert_eq!(
2181            root["payment_provider_accounts"][0]["connection_mode"],
2182            "direct_credentials"
2183        );
2184        assert_eq!(
2185            root["payment_provider_accounts"][0]["checkout_mode"],
2186            "live"
2187        );
2188
2189        payment_provider_set_checkout_mode(
2190            &mut root,
2191            &app_root,
2192            "acme-audio",
2193            "stripe",
2194            11,
2195            "demo",
2196        )
2197        .expect("set checkout demo mode");
2198        assert_eq!(
2199            root["payment_provider_accounts"][0]["checkout_mode"],
2200            "demo"
2201        );
2202        payment_provider_set_checkout_mode(
2203            &mut root,
2204            &app_root,
2205            "acme-audio",
2206            "stripe",
2207            11,
2208            "live",
2209        )
2210        .expect("set checkout live mode with saved credentials");
2211        assert_eq!(
2212            root["payment_provider_accounts"][0]["checkout_mode"],
2213            "live"
2214        );
2215
2216        payment_provider_revoke_credentials(
2217            &mut root,
2218            &app_root,
2219            Some(conf_path.as_path()),
2220            "acme-audio",
2221            "stripe",
2222            11,
2223        )
2224        .expect("revoke credentials");
2225        assert_eq!(
2226            root["payment_provider_accounts"][0]["checkout_mode"],
2227            "demo"
2228        );
2229        assert_eq!(
2230            root["payment_provider_accounts"][0]["connection_mode"],
2231            "hosted_onboarding"
2232        );
2233        assert!(
2234            root["payment_provider_secrets"]
2235                .as_array()
2236                .expect("secret rows")
2237                .iter()
2238                .all(|row| row.get("revoked_at").and_then(Value::as_str).is_some())
2239        );
2240        assert_eq!(
2241            payment_provider_set_checkout_mode(
2242                &mut root,
2243                &app_root,
2244                "acme-audio",
2245                "stripe",
2246                11,
2247                "live",
2248            ),
2249            Err(409)
2250        );
2251
2252        fs::remove_dir_all(app_root).expect("remove payment provider credentials root");
2253    }
2254
2255    #[test]
2256    fn payment_provider_test_requires_saved_direct_credentials() {
2257        let app_root = temp_app_root("payment-provider-test-requires-credentials");
2258        let conf_path = write_payment_provider_fixture_app(&app_root);
2259        let mut root = seed_payment_provider_root();
2260
2261        let result = payment_provider_test_connection(
2262            &mut root,
2263            &app_root,
2264            Some(conf_path.as_path()),
2265            "acme-audio",
2266            "stripe",
2267            11,
2268            PaymentProviderConnectRequest::default(),
2269        );
2270
2271        assert_eq!(
2272            result,
2273            Err((
2274                "provider_credentials_required".to_owned(),
2275                "Save provider credentials before testing the connection.".to_owned(),
2276                409
2277            ))
2278        );
2279        fs::remove_dir_all(app_root).expect("remove payment provider test root");
2280    }
2281
2282    #[test]
2283    fn payment_provider_connect_uses_owner_scoped_client_id_when_saved() {
2284        let app_root = temp_app_root("payment-provider-owner-client-id");
2285        let conf_path = write_payment_provider_fixture_app(&app_root);
2286        let mut root = seed_payment_provider_root();
2287
2288        payment_provider_configure_credentials(
2289            &mut root,
2290            &app_root,
2291            Some(conf_path.as_path()),
2292            "acme-audio",
2293            "stripe",
2294            11,
2295            PaymentProviderCredentialSubmission {
2296                fields: vec![
2297                    ("stripe_client_id".to_owned(), "ca_owner_456".to_owned()),
2298                    (
2299                        "stripe_client_secret".to_owned(),
2300                        "sk_test_owner_secret".to_owned(),
2301                    ),
2302                ],
2303            },
2304        )
2305        .expect("configure credentials");
2306        let url = payment_provider_connect_start_with_request(
2307            &mut root,
2308            &app_root,
2309            Some(conf_path.as_path()),
2310            "acme-audio",
2311            "stripe",
2312            11,
2313            PaymentProviderConnectRequest {
2314                provider_account_label: Some("Owner Stripe"),
2315            },
2316        )
2317        .expect("connect start");
2318
2319        assert!(url.contains("client_id=ca_owner_456"));
2320        assert!(!url.contains("ca_test_123"));
2321
2322        fs::remove_dir_all(app_root).expect("remove payment provider owner-client-id root");
2323    }
2324
2325    fn seed_connected_payment_provider_account(root: &mut Value) {
2326        for (status, onboarding_state, charges_enabled, payouts_enabled, details_submitted) in [
2327            (
2328                PaymentProviderConnectionState::ConnectRequested.as_str(),
2329                "connect_requested",
2330                false,
2331                false,
2332                false,
2333            ),
2334            (
2335                PaymentProviderConnectionState::RedirectPending.as_str(),
2336                "oauth_redirect",
2337                false,
2338                false,
2339                false,
2340            ),
2341            (
2342                PaymentProviderConnectionState::CallbackReceived.as_str(),
2343                "callback_received",
2344                false,
2345                false,
2346                false,
2347            ),
2348            (
2349                PaymentProviderConnectionState::Connected.as_str(),
2350                "ready",
2351                true,
2352                true,
2353                true,
2354            ),
2355        ] {
2356            root.upsert_account_state(PaymentProviderAccountUpdate {
2357                app_id: "commerce_test",
2358                tenant_id: 91,
2359                owner_resource_type: "merchant",
2360                owner_resource_id: 7,
2361                provider_id: "stripe",
2362                actor_user_id: 11,
2363                status,
2364                onboarding_state,
2365                provider_account_id: Some("acct_123456789"),
2366                provider_account_label: Some("Acme Audio Stripe"),
2367                last_error_code: None,
2368                last_error_message: None,
2369                charges_enabled,
2370                payouts_enabled,
2371                details_submitted,
2372            })
2373            .expect("seed connected account");
2374        }
2375    }
2376
2377    fn connect_state_from_redirect(location: &str) -> String {
2378        let needle = "state=";
2379        let start = location.find(needle).expect("state query") + needle.len();
2380        let tail = &location[start..];
2381        let encoded = tail.split('&').next().unwrap_or_default();
2382        let mut out = String::with_capacity(encoded.len());
2383        let mut chars = encoded.as_bytes().iter().copied();
2384        while let Some(byte) = chars.next() {
2385            if byte == b'%' {
2386                let hi = chars.next().expect("percent hi") as char;
2387                let lo = chars.next().expect("percent lo") as char;
2388                let value = u8::from_str_radix(&format!("{hi}{lo}"), 16).expect("hex decode");
2389                out.push(char::from(value));
2390            } else {
2391                out.push(char::from(byte));
2392            }
2393        }
2394        out
2395    }
2396
2397    #[test]
2398    fn payment_provider_state_token_binds_app_tenant_owner_and_revision() {
2399        let app_root = temp_app_root("payment-provider-token");
2400        let conf_path = write_payment_provider_fixture_app(&app_root);
2401        let token = payment_provider_state_token(
2402            &app_root,
2403            Some(conf_path.as_path()),
2404            "commerce_test",
2405            91,
2406            7,
2407            "acme-audio",
2408            "stripe",
2409            11,
2410            3,
2411            "nonce-123",
2412            "2099-01-01T00:00:00Z",
2413        )
2414        .expect("token");
2415        let decoded =
2416            verify_payment_provider_state_token(&app_root, Some(conf_path.as_path()), &token)
2417                .expect("verified");
2418        assert_eq!(decoded.app_id, "commerce_test");
2419        assert_eq!(decoded.tenant_id, 91);
2420        assert_eq!(decoded.owner_resource_id, 7);
2421        assert_eq!(decoded.owner_route_value, "acme-audio");
2422        assert_eq!(decoded.provider, "stripe");
2423        assert_eq!(decoded.actor_user_id, 11);
2424        assert_eq!(decoded.revision, 3);
2425    }
2426
2427    #[test]
2428    fn payment_provider_callback_rejects_replayed_nonce() {
2429        let app_root = temp_app_root("payment-provider-replay");
2430        let conf_path = write_payment_provider_fixture_app(&app_root);
2431        let state_path = app_root.join("runtime-state.json");
2432        let mut root = seed_payment_provider_root();
2433        stripe_test_set_responses(vec![
2434            Ok((
2435                200,
2436                serde_json::json!({
2437                    "stripe_user_id": "acct_123456789",
2438                }),
2439            )),
2440            Ok((
2441                200,
2442                serde_json::json!({
2443                    "id": "acct_123456789",
2444                    "charges_enabled": true,
2445                    "payouts_enabled": true,
2446                    "details_submitted": true,
2447                    "email": "billing@example.test",
2448                    "requirements": { "disabled_reason": null }
2449                }),
2450            )),
2451        ]);
2452        let redirect = payment_provider_connect_start(
2453            &mut root,
2454            &app_root,
2455            Some(conf_path.as_path()),
2456            "acme-audio",
2457            "stripe",
2458            11,
2459        )
2460        .expect("connect start");
2461        let state = connect_state_from_redirect(&redirect);
2462        persist_payment_provider_state_or_500(&root, &state_path).expect("persist connect state");
2463        let mut root =
2464            json_commands::json_doc_load_file(&state_path).expect("reload connect state");
2465        payment_provider_callback_complete(
2466            &mut root,
2467            &app_root,
2468            Some(conf_path.as_path()),
2469            "acme-audio",
2470            "stripe",
2471            &state,
2472            "auth_code_123",
2473            11,
2474        )
2475        .expect("first callback");
2476        persist_payment_provider_state_or_500(&root, &state_path).expect("persist callback state");
2477        let mut root =
2478            json_commands::json_doc_load_file(&state_path).expect("reload callback state");
2479        let account = &root["payment_provider_accounts"][0];
2480        assert_eq!(account["connection_state"], "connected");
2481        assert_eq!(account["capability_state"], "fully_enabled");
2482        assert!(
2483            root["payment_provider_callback_nonces"][0]["claimed_at"]
2484                .as_str()
2485                .is_some_and(|value| !value.is_empty())
2486        );
2487        assert_eq!(
2488            root["payment_provider_audit_events"]
2489                .as_array()
2490                .map(Vec::len),
2491            Some(4)
2492        );
2493        assert_eq!(
2494            root["payment_provider_audit_events"][0]["event_state_before"],
2495            "never_started"
2496        );
2497        assert_eq!(
2498            root["payment_provider_audit_events"][3]["event_state_after"],
2499            "connected"
2500        );
2501        let replay = payment_provider_callback_complete(
2502            &mut root,
2503            &app_root,
2504            Some(conf_path.as_path()),
2505            "acme-audio",
2506            "stripe",
2507            &state,
2508            "auth_code_123",
2509            11,
2510        );
2511        assert_eq!(replay, Err(409));
2512        fs::remove_dir_all(app_root).expect("remove payment provider replay root");
2513    }
2514
2515    #[test]
2516    fn payment_provider_transition_graph_rejects_out_of_order_states() {
2517        use PaymentProviderConnectionState as State;
2518
2519        assert!(State::NeverStarted.can_transition_to(State::ConnectRequested));
2520        assert!(State::ConnectRequested.can_transition_to(State::RedirectPending));
2521        assert!(State::RedirectPending.can_transition_to(State::CallbackReceived));
2522        assert!(State::CallbackReceived.can_transition_to(State::Connected));
2523        assert!(State::Connected.can_transition_to(State::DisconnectRequested));
2524        assert!(State::DisconnectRequested.can_transition_to(State::Disconnected));
2525        assert!(State::Connected.can_transition_to(State::Connected));
2526        assert!(!State::NeverStarted.can_transition_to(State::Connected));
2527        assert!(!State::Disconnected.can_transition_to(State::CallbackReceived));
2528        assert!(!State::DisconnectRequested.can_transition_to(State::Connected));
2529    }
2530
2531    #[test]
2532    fn payment_provider_account_mutation_fails_closed_on_invalid_transition() {
2533        let mut root = seed_payment_provider_root();
2534        let result = root.upsert_account_state(PaymentProviderAccountUpdate {
2535            app_id: "commerce_test",
2536            tenant_id: 91,
2537            owner_resource_type: "merchant",
2538            owner_resource_id: 7,
2539            provider_id: "stripe",
2540            actor_user_id: 11,
2541            status: PaymentProviderConnectionState::Connected.as_str(),
2542            onboarding_state: "ready",
2543            provider_account_id: Some("acct_invalid_transition"),
2544            provider_account_label: Some("Invalid transition"),
2545            last_error_code: None,
2546            last_error_message: None,
2547            charges_enabled: true,
2548            payouts_enabled: true,
2549            details_submitted: true,
2550        });
2551        assert_eq!(result, Err(409));
2552        assert!(
2553            root["payment_provider_accounts"]
2554                .as_array()
2555                .is_some_and(Vec::is_empty)
2556        );
2557    }
2558
2559    #[test]
2560    fn payment_provider_adapter_dispatches_supported_provider_only() {
2561        assert_eq!(
2562            pharos_runtime_payment::BuiltInPaymentProviderAdapterRegistry
2563                .adapter("stripe")
2564                .ok()
2565                .map(PaymentProviderAdapter::provider_id),
2566            Some("stripe")
2567        );
2568        assert!(
2569            pharos_runtime_payment::BuiltInPaymentProviderAdapterRegistry
2570                .adapter("paypal")
2571                .is_err()
2572        );
2573    }
2574
2575    #[test]
2576    fn payment_provider_callback_failure_persists_redacted_detail() {
2577        let app_root = temp_app_root("payment-provider-redaction");
2578        let conf_path = write_payment_provider_fixture_app(&app_root);
2579        let mut root = seed_payment_provider_root();
2580        stripe_test_set_responses(vec![Ok((
2581            400,
2582            serde_json::json!({
2583                "error": {
2584                    "code": "invalid_request_error",
2585                    "message": "Stripe rejected secret sk_test_should_not_persist"
2586                }
2587            }),
2588        ))]);
2589        let redirect = payment_provider_connect_start(
2590            &mut root,
2591            &app_root,
2592            Some(conf_path.as_path()),
2593            "acme-audio",
2594            "stripe",
2595            11,
2596        )
2597        .expect("connect start");
2598        let state = connect_state_from_redirect(&redirect);
2599        let callback = payment_provider_callback_complete(
2600            &mut root,
2601            &app_root,
2602            Some(conf_path.as_path()),
2603            "acme-audio",
2604            "stripe",
2605            &state,
2606            "auth_code_123",
2607            11,
2608        );
2609        assert_eq!(callback, Err(400));
2610        let account = &root["payment_provider_accounts"][0];
2611        assert_eq!(account["connection_state"], "failed");
2612        assert_eq!(account["status_reason_code"], "invalid_request_error");
2613        assert_eq!(
2614            account["status_reason_detail"],
2615            "Stripe rejected secret [redacted]"
2616        );
2617        assert!(
2618            !account["status_reason_detail"]
2619                .as_str()
2620                .unwrap_or_default()
2621                .contains("sk_test_should_not_persist")
2622        );
2623        fs::remove_dir_all(app_root).expect("remove payment provider redaction root");
2624    }
2625
2626    #[test]
2627    fn payment_provider_callback_rejects_expired_state() {
2628        let app_root = temp_app_root("payment-provider-expired-state");
2629        let conf_path = write_payment_provider_fixture_app(&app_root);
2630        let mut root = seed_payment_provider_root();
2631        let state = payment_provider_state_token(
2632            &app_root,
2633            Some(conf_path.as_path()),
2634            "commerce_test",
2635            91,
2636            7,
2637            "acme-audio",
2638            "stripe",
2639            11,
2640            1,
2641            "expired-nonce",
2642            "2000-01-01T00:00:00Z",
2643        )
2644        .expect("expired token");
2645        let callback = payment_provider_callback_complete(
2646            &mut root,
2647            &app_root,
2648            Some(conf_path.as_path()),
2649            "acme-audio",
2650            "stripe",
2651            &state,
2652            "auth_code_123",
2653            11,
2654        );
2655        assert_eq!(callback, Err(422));
2656        assert!(
2657            root["payment_provider_audit_events"]
2658                .as_array()
2659                .is_some_and(Vec::is_empty)
2660        );
2661        fs::remove_dir_all(app_root).expect("remove payment provider expired root");
2662    }
2663
2664    #[test]
2665    fn payment_provider_connect_config_failure_does_not_persist_partial_state() {
2666        let app_root = temp_app_root("payment-provider-connect-config");
2667        write_payment_provider_fixture_app(&app_root);
2668        let conf_path = app_root.join("app.conf");
2669        fs::write(
2670            &conf_path,
2671            "base_url=http://127.0.0.1:8084/commerce-test\npayment_providers=stripe\npayment_provider_state_key_env=PATH\n",
2672        )
2673        .expect("write config");
2674        let mut root = seed_payment_provider_root();
2675        let result = payment_provider_connect_start(
2676            &mut root,
2677            &app_root,
2678            Some(conf_path.as_path()),
2679            "acme-audio",
2680            "stripe",
2681            11,
2682        );
2683        assert_eq!(result, Err(503));
2684        assert!(
2685            root["payment_provider_accounts"]
2686                .as_array()
2687                .is_some_and(Vec::is_empty)
2688        );
2689        fs::remove_dir_all(app_root).expect("remove payment provider connect-config root");
2690    }
2691
2692    #[test]
2693    fn payment_provider_callback_rejects_actor_and_owner_mismatch() {
2694        let app_root = temp_app_root("payment-provider-mismatch");
2695        let conf_path = write_payment_provider_fixture_app(&app_root);
2696        let mut root = seed_payment_provider_root();
2697        let redirect = payment_provider_connect_start(
2698            &mut root,
2699            &app_root,
2700            Some(conf_path.as_path()),
2701            "acme-audio",
2702            "stripe",
2703            11,
2704        )
2705        .expect("connect start");
2706        let state = connect_state_from_redirect(&redirect);
2707        let actor_mismatch = payment_provider_callback_complete(
2708            &mut root,
2709            &app_root,
2710            Some(conf_path.as_path()),
2711            "acme-audio",
2712            "stripe",
2713            &state,
2714            "auth_code_123",
2715            12,
2716        );
2717        assert_eq!(actor_mismatch, Err(403));
2718        assert_eq!(
2719            root["payment_provider_callback_nonces"][0]["claimed_at"],
2720            ""
2721        );
2722
2723        let owner_mismatch = payment_provider_callback_complete(
2724            &mut root,
2725            &app_root,
2726            Some(conf_path.as_path()),
2727            "wrong-merchant",
2728            "stripe",
2729            &state,
2730            "auth_code_123",
2731            0,
2732        );
2733        assert_eq!(owner_mismatch, Err(422));
2734        assert_eq!(
2735            root["payment_provider_callback_nonces"][0]["claimed_at"],
2736            ""
2737        );
2738        fs::remove_dir_all(app_root).expect("remove payment provider mismatch root");
2739    }
2740
2741    #[test]
2742    fn payment_provider_callback_missing_code_persists_stable_reason() {
2743        let app_root = temp_app_root("payment-provider-missing-code");
2744        let conf_path = write_payment_provider_fixture_app(&app_root);
2745        let mut root = seed_payment_provider_root();
2746        let redirect = payment_provider_connect_start(
2747            &mut root,
2748            &app_root,
2749            Some(conf_path.as_path()),
2750            "acme-audio",
2751            "stripe",
2752            11,
2753        )
2754        .expect("connect start");
2755        let state = connect_state_from_redirect(&redirect);
2756        let callback = payment_provider_callback_complete(
2757            &mut root,
2758            &app_root,
2759            Some(conf_path.as_path()),
2760            "acme-audio",
2761            "stripe",
2762            &state,
2763            "",
2764            11,
2765        );
2766        assert_eq!(callback, Err(422));
2767        let account = &root["payment_provider_accounts"][0];
2768        assert_eq!(account["connection_state"], "failed");
2769        assert_eq!(
2770            account["status_reason_code"],
2771            "provider_callback_missing_code"
2772        );
2773        assert_eq!(
2774            root["payment_provider_audit_events"][3]["reason_code"],
2775            "provider_callback_missing_code"
2776        );
2777        fs::remove_dir_all(app_root).expect("remove payment provider missing-code root");
2778    }
2779
2780    #[test]
2781    fn payment_provider_refresh_marks_provider_revocation() {
2782        let app_root = temp_app_root("payment-provider-refresh-revoked");
2783        let conf_path = write_payment_provider_fixture_app(&app_root);
2784        let mut root = seed_payment_provider_root();
2785        seed_connected_payment_provider_account(&mut root);
2786        stripe_test_set_responses(vec![Ok((
2787            404,
2788            serde_json::json!({
2789                "error": {
2790                    "code": "account_invalid",
2791                    "message": "No such connected account sk_test_should_not_persist"
2792                }
2793            }),
2794        ))]);
2795        let refresh = payment_provider_refresh(
2796            &mut root,
2797            &app_root,
2798            Some(conf_path.as_path()),
2799            "acme-audio",
2800            "stripe",
2801            11,
2802        );
2803        assert_eq!(refresh, Err(404));
2804        let account = &root["payment_provider_accounts"][0];
2805        assert_eq!(account["connection_state"], "revoked_externally");
2806        assert_eq!(account["onboarding_state"], "revoked_externally");
2807        assert_eq!(account["status_reason_code"], "account_invalid");
2808        assert_eq!(
2809            account["status_reason_detail"],
2810            "No such connected account [redacted]"
2811        );
2812        assert_eq!(
2813            root["payment_provider_audit_events"][0]["reason_code"],
2814            "account_invalid"
2815        );
2816        fs::remove_dir_all(app_root).expect("remove payment provider refresh revoked root");
2817    }
2818
2819    #[test]
2820    fn payment_provider_refresh_verifies_saved_direct_credentials_without_provider_account() {
2821        let app_root = temp_app_root("payment-provider-refresh-direct-credentials");
2822        let conf_path = write_payment_provider_fixture_app(&app_root);
2823        let mut root = seed_payment_provider_root();
2824
2825        payment_provider_configure_credentials(
2826            &mut root,
2827            &app_root,
2828            Some(conf_path.as_path()),
2829            "acme-audio",
2830            "stripe",
2831            11,
2832            PaymentProviderCredentialSubmission {
2833                fields: vec![
2834                    ("stripe_client_id".to_owned(), "acct_owner_123".to_owned()),
2835                    (
2836                        "stripe_client_secret".to_owned(),
2837                        "sk_test_owner_secret".to_owned(),
2838                    ),
2839                ],
2840            },
2841        )
2842        .expect("configure direct credentials");
2843        assert_eq!(
2844            root["payment_provider_accounts"][0]["connection_mode"],
2845            "direct_credentials"
2846        );
2847        assert!(
2848            root["payment_provider_accounts"][0]["provider_account_id"]
2849                .as_str()
2850                .unwrap_or_default()
2851                .is_empty()
2852        );
2853
2854        stripe_test_set_responses(vec![Ok((
2855            200,
2856            serde_json::json!({
2857                "id": "acct_owner_123",
2858                "charges_enabled": true,
2859                "payouts_enabled": true,
2860                "details_submitted": true,
2861                "business_profile": {"name": "Owner direct credentials"},
2862                "requirements": {"currently_due": [], "disabled_reason": null}
2863            }),
2864        ))]);
2865        payment_provider_refresh(
2866            &mut root,
2867            &app_root,
2868            Some(conf_path.as_path()),
2869            "acme-audio",
2870            "stripe",
2871            11,
2872        )
2873        .expect("refresh direct credentials");
2874
2875        let account = &root["payment_provider_accounts"][0];
2876        assert_eq!(account["connection_mode"], "direct_credentials");
2877        assert_eq!(account["checkout_mode"], "live");
2878        assert_eq!(account["connection_state"], "connected");
2879        assert_eq!(account["onboarding_state"], "ready");
2880        assert!(
2881            account["provider_account_id"]
2882                .as_str()
2883                .unwrap_or_default()
2884                .is_empty()
2885        );
2886        assert_eq!(
2887            account["provider_account_label"],
2888            "Owner direct credentials"
2889        );
2890        assert_eq!(account["status_reason_code"], Value::Null);
2891        assert_eq!(
2892            account["status_reason_detail"],
2893            "Stripe account linked and charges are enabled."
2894        );
2895        let audit_json =
2896            serde_json::to_string(&root["payment_provider_audit_events"]).expect("audit json");
2897        assert!(audit_json.contains("provider_credentials_verified"));
2898        assert!(!audit_json.contains("acct_owner_123"));
2899        assert!(!audit_json.contains("sk_test_owner_secret"));
2900
2901        fs::remove_dir_all(app_root)
2902            .expect("remove payment provider refresh direct credentials root");
2903    }
2904
2905    #[test]
2906    fn payment_provider_test_verifies_replaced_direct_credentials_after_previous_lifecycle() {
2907        for previous_state in [
2908            "disconnected",
2909            "redirect_pending",
2910            "connect_requested",
2911            "callback_received",
2912            "connected",
2913            "restricted",
2914        ] {
2915            let app_root = temp_app_root("payment-provider-replaced-direct-credentials");
2916            let conf_path = write_payment_provider_fixture_app(&app_root);
2917            let mut root = seed_payment_provider_root();
2918            root.upsert_account_state(PaymentProviderAccountUpdate {
2919                app_id: "commerce_test",
2920                tenant_id: 91,
2921                owner_resource_type: "merchant",
2922                owner_resource_id: 7,
2923                provider_id: "stripe",
2924                actor_user_id: 11,
2925                status: PaymentProviderConnectionState::NeverStarted.as_str(),
2926                onboarding_state: "credentials_deleted",
2927                provider_account_id: None,
2928                provider_account_label: Some("Stripe credentials deleted"),
2929                last_error_code: None,
2930                last_error_message: None,
2931                charges_enabled: false,
2932                payouts_enabled: false,
2933                details_submitted: false,
2934            })
2935            .expect("seed never started account");
2936            if let Some(row) = payment_provider_account_row_mut(
2937                &mut root,
2938                "commerce_test",
2939                "merchant",
2940                7,
2941                "stripe",
2942            ) && let Some(object) = row.as_object_mut()
2943            {
2944                object.insert(
2945                    "connection_state".to_owned(),
2946                    Value::String(previous_state.to_owned()),
2947                );
2948                object.insert(
2949                    "connection_mode".to_owned(),
2950                    Value::String("hosted_onboarding".to_owned()),
2951                );
2952                object.insert("checkout_mode".to_owned(), Value::String("demo".to_owned()));
2953            }
2954
2955            let previous_revision = root["payment_provider_accounts"][0]["revision"]
2956                .as_i64()
2957                .unwrap();
2958            payment_provider_configure_credentials(
2959                &mut root,
2960                &app_root,
2961                Some(conf_path.as_path()),
2962                "acme-audio",
2963                "stripe",
2964                11,
2965                PaymentProviderCredentialSubmission {
2966                    fields: vec![
2967                        ("stripe_client_id".to_owned(), "acct_owner_456".to_owned()),
2968                        (
2969                            "stripe_client_secret".to_owned(),
2970                            "sk_test_owner_secret_456".to_owned(),
2971                        ),
2972                    ],
2973                },
2974            )
2975            .expect("replace direct credentials");
2976            assert_eq!(
2977                root["payment_provider_accounts"][0]["connection_state"],
2978                "verification_pending"
2979            );
2980            assert_eq!(
2981                root["payment_provider_accounts"][0]["connection_mode"],
2982                "direct_credentials"
2983            );
2984
2985            assert_eq!(
2986                root["payment_provider_accounts"][0]["revision"],
2987                previous_revision + 1
2988            );
2989            assert_eq!(
2990                root["payment_provider_accounts"][0]["charges_enabled"],
2991                false
2992            );
2993            let stale_state = payment_provider_state_token(
2994                &app_root,
2995                Some(conf_path.as_path()),
2996                "commerce_test",
2997                91,
2998                7,
2999                "acme-audio",
3000                "stripe",
3001                11,
3002                previous_revision,
3003                "old-credential-nonce",
3004                "2099-01-01T00:00:00Z",
3005            )
3006            .expect("old revision token");
3007            assert_eq!(
3008                payment_provider_callback_complete(
3009                    &mut root,
3010                    &app_root,
3011                    Some(conf_path.as_path()),
3012                    "acme-audio",
3013                    "stripe",
3014                    &stale_state,
3015                    "old_authorization",
3016                    11,
3017                ),
3018                Err(409)
3019            );
3020
3021            stripe_test_set_responses(vec![Ok((
3022                200,
3023                serde_json::json!({
3024                    "id": "acct_owner_456",
3025                    "charges_enabled": true,
3026                    "payouts_enabled": true,
3027                    "details_submitted": true,
3028                    "business_profile": {"name": "Replaced direct credentials"},
3029                    "requirements": {"currently_due": [], "disabled_reason": null}
3030                }),
3031            ))]);
3032            payment_provider_test_connection(
3033                &mut root,
3034                &app_root,
3035                Some(conf_path.as_path()),
3036                "acme-audio",
3037                "stripe",
3038                11,
3039                PaymentProviderConnectRequest::default(),
3040            )
3041            .expect("test replaced direct credentials after disconnect");
3042
3043            let account = &root["payment_provider_accounts"][0];
3044            assert_eq!(account["connection_mode"], "direct_credentials");
3045            assert_eq!(account["connection_state"], "connected");
3046            assert_eq!(account["checkout_mode"], "live");
3047            assert_eq!(
3048                account["provider_account_label"],
3049                "Replaced direct credentials"
3050            );
3051            fs::remove_dir_all(app_root).expect("remove replaced direct credentials root");
3052        }
3053    }
3054
3055    #[test]
3056    fn payment_provider_disconnect_failure_persists_redacted_reason() {
3057        let app_root = temp_app_root("payment-provider-disconnect-failure");
3058        let conf_path = write_payment_provider_fixture_app(&app_root);
3059        let mut root = seed_payment_provider_root();
3060        seed_connected_payment_provider_account(&mut root);
3061        stripe_test_set_responses(vec![Ok((
3062            500,
3063            serde_json::json!({
3064                "error": {
3065                    "code": "api_error",
3066                    "message": "Stripe failed while handling sk_test_should_not_persist"
3067                }
3068            }),
3069        ))]);
3070        let disconnect = payment_provider_disconnect(
3071            &mut root,
3072            &app_root,
3073            Some(conf_path.as_path()),
3074            "acme-audio",
3075            "stripe",
3076            11,
3077        );
3078        assert_eq!(disconnect, Err(500));
3079        let account = &root["payment_provider_accounts"][0];
3080        assert_eq!(account["connection_state"], "failed");
3081        assert_eq!(account["onboarding_state"], "disconnect_failed");
3082        assert_eq!(account["status_reason_code"], "api_error");
3083        assert_eq!(
3084            account["status_reason_detail"],
3085            "Stripe failed while handling [redacted]"
3086        );
3087        assert_eq!(
3088            root["payment_provider_audit_events"]
3089                .as_array()
3090                .map(Vec::len),
3091            Some(2)
3092        );
3093        assert_eq!(
3094            root["payment_provider_audit_events"][1]["event_type"],
3095            "disconnect_failed"
3096        );
3097        fs::remove_dir_all(app_root).expect("remove payment provider disconnect failure root");
3098    }
3099
3100    #[test]
3101    fn payment_provider_disconnect_is_idempotent_after_disconnect() {
3102        let app_root = temp_app_root("payment-provider-disconnect-idempotent");
3103        let conf_path = write_payment_provider_fixture_app(&app_root);
3104        let mut root = seed_payment_provider_root();
3105        seed_connected_payment_provider_account(&mut root);
3106        stripe_test_set_responses(vec![Ok((
3107            200,
3108            serde_json::json!({"stripe_user_id": "acct_123456789"}),
3109        ))]);
3110        payment_provider_disconnect(
3111            &mut root,
3112            &app_root,
3113            Some(conf_path.as_path()),
3114            "acme-audio",
3115            "stripe",
3116            11,
3117        )
3118        .expect("first disconnect");
3119        let revision = root["payment_provider_accounts"][0]["revision"]
3120            .as_i64()
3121            .expect("revision");
3122        let audit_count = root["payment_provider_audit_events"]
3123            .as_array()
3124            .map(Vec::len)
3125            .unwrap_or_default();
3126        payment_provider_disconnect(
3127            &mut root,
3128            &app_root,
3129            Some(conf_path.as_path()),
3130            "acme-audio",
3131            "stripe",
3132            11,
3133        )
3134        .expect("second disconnect");
3135        assert_eq!(
3136            root["payment_provider_accounts"][0]["connection_state"],
3137            "disconnected"
3138        );
3139        assert_eq!(root["payment_provider_accounts"][0]["revision"], revision);
3140        assert_eq!(
3141            root["payment_provider_audit_events"]
3142                .as_array()
3143                .map(Vec::len),
3144            Some(audit_count)
3145        );
3146        fs::remove_dir_all(app_root).expect("remove payment provider disconnect idempotent root");
3147    }
3148
3149    #[test]
3150    fn stripe_account_status_maps_restricted_accounts() {
3151        let (
3152            status,
3153            onboarding_state,
3154            charges_enabled,
3155            payouts_enabled,
3156            details_submitted,
3157            reason_code,
3158            reason_detail,
3159        ) = stripe_account_status(&serde_json::json!({
3160            "charges_enabled": false,
3161            "payouts_enabled": false,
3162            "details_submitted": true,
3163            "requirements": {
3164                "disabled_reason": "requirements.past_due"
3165            }
3166        }));
3167        assert_eq!(status, "restricted");
3168        assert_eq!(onboarding_state, "restricted");
3169        assert!(!charges_enabled);
3170        assert!(!payouts_enabled);
3171        assert!(details_submitted);
3172        assert_eq!(reason_code.as_deref(), Some("requirements.past_due"));
3173        assert!(
3174            reason_detail
3175                .as_deref()
3176                .is_some_and(|value| value.contains("requirements.past_due"))
3177        );
3178    }
3179}