1#[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
125pub 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
166pub 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
642pub 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}
745pub 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}