1use crate::CommerceModelError;
2use coil_config::{PlatformConfig, SecretRef};
3use coil_core::{
4 CoreServiceDependency, ModuleBehavior, ModuleDependency, ModuleManifest, PlatformModule,
5 RegistrationError, ServiceRegistry,
6};
7
8pub const STRIPE_MODULE_NAME: &str = "commerce-payments-stripe";
9pub const STRIPE_CONFIG_NAMESPACE: &str = "commerce_payments_stripe";
10pub const STRIPE_PROVIDER_CODE: &str = "stripe";
11pub const STRIPE_PROVIDER_LABEL: &str = "Stripe";
12pub const STRIPE_SERVICE_ID: &str = "module.commerce.payments.stripe";
13pub const STRIPE_PAYMENT_WEBHOOK_ROUTE: &str = "/webhooks/commerce/payment-provider";
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum StripeCheckoutMode {
17 WebhookConfirmation,
18 HostedCheckout,
19}
20
21impl StripeCheckoutMode {
22 pub fn as_str(self) -> &'static str {
23 match self {
24 Self::WebhookConfirmation => "webhook-confirmation",
25 Self::HostedCheckout => "hosted-checkout",
26 }
27 }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct StripeProviderMetadata {
32 pub module_name: String,
33 pub service_id: String,
34 pub provider_code: String,
35 pub provider_label: String,
36 pub checkout_mode: StripeCheckoutMode,
37 pub webhook_route: String,
38 pub publishable_key_ref: String,
39 pub webhook_secret_ref: String,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct CommercePaymentsStripeConfig {
44 pub provider: String,
45 pub checkout_mode: StripeCheckoutMode,
46 pub publishable_key: SecretRef,
47 pub webhook_secret: SecretRef,
48}
49
50pub struct CommercePaymentsStripeModule;
51
52impl CommercePaymentsStripeModule {
53 pub fn new() -> Self {
54 Self
55 }
56}
57
58impl Default for CommercePaymentsStripeModule {
59 fn default() -> Self {
60 Self::new()
61 }
62}
63
64impl CommercePaymentsStripeModule {
65 pub fn provider_metadata(config: &CommercePaymentsStripeConfig) -> StripeProviderMetadata {
66 StripeProviderMetadata {
67 module_name: STRIPE_MODULE_NAME.to_string(),
68 service_id: STRIPE_SERVICE_ID.to_string(),
69 provider_code: STRIPE_PROVIDER_CODE.to_string(),
70 provider_label: STRIPE_PROVIDER_LABEL.to_string(),
71 checkout_mode: config.checkout_mode,
72 webhook_route: STRIPE_PAYMENT_WEBHOOK_ROUTE.to_string(),
73 publishable_key_ref: config.publishable_key.redacted(),
74 webhook_secret_ref: config.webhook_secret.redacted(),
75 }
76 }
77}
78
79impl CommercePaymentsStripeConfig {
80 pub fn from_platform_config(
81 config: &PlatformConfig,
82 ) -> Result<Option<Self>, CommerceModelError> {
83 let enabled = config
84 .modules
85 .enabled
86 .iter()
87 .any(|module| module == STRIPE_MODULE_NAME);
88 let Some(settings) = config.modules.settings.get(STRIPE_MODULE_NAME) else {
89 return if enabled {
90 Err(CommerceModelError::MissingModuleSetting {
91 module: STRIPE_MODULE_NAME.to_string(),
92 field: format!("[modules.\"{STRIPE_MODULE_NAME}\"]"),
93 })
94 } else {
95 Ok(None)
96 };
97 };
98 let Some(table) = settings.as_table() else {
99 return Err(CommerceModelError::InvalidModuleSetting {
100 module: STRIPE_MODULE_NAME.to_string(),
101 field: format!("[modules.\"{STRIPE_MODULE_NAME}\"]"),
102 reason: "expected a table of provider settings".to_string(),
103 });
104 };
105
106 let provider = module_string_setting(table, "provider")?.to_ascii_lowercase();
107 if provider != STRIPE_PROVIDER_CODE {
108 return Err(CommerceModelError::UnsupportedModuleSetting {
109 module: STRIPE_MODULE_NAME.to_string(),
110 field: "provider".to_string(),
111 value: provider,
112 });
113 }
114
115 let checkout_mode_raw = module_string_setting(table, "checkout_mode")?.to_ascii_lowercase();
116 let checkout_mode = match checkout_mode_raw.as_str() {
117 "webhook-confirmation" => StripeCheckoutMode::WebhookConfirmation,
118 "hosted-checkout" | "hosted_checkout" | "stripe-hosted-checkout" => {
119 StripeCheckoutMode::HostedCheckout
120 }
121 other => {
122 return Err(CommerceModelError::UnsupportedModuleSetting {
123 module: STRIPE_MODULE_NAME.to_string(),
124 field: "checkout_mode".to_string(),
125 value: other.to_string(),
126 });
127 }
128 };
129
130 let publishable_key = module_secret_setting(table, "publishable_key")?;
131 let webhook_secret = module_secret_setting(table, "webhook_secret")?;
132
133 Ok(Some(Self {
134 provider,
135 checkout_mode,
136 publishable_key,
137 webhook_secret,
138 }))
139 }
140
141 pub fn provider_metadata(&self) -> StripeProviderMetadata {
142 CommercePaymentsStripeModule::provider_metadata(self)
143 }
144}
145
146impl PlatformModule for CommercePaymentsStripeModule {
147 fn manifest(&self) -> ModuleManifest {
148 ModuleManifest::new(STRIPE_MODULE_NAME)
149 .with_config_namespace(STRIPE_CONFIG_NAMESPACE.to_string())
150 .with_module_dependencies(vec![ModuleDependency::required(
151 "commerce",
152 "Stripe checkout handoff and signed webhook reconciliation extend the base commerce checkout lifecycle",
153 )])
154 .with_core_service_dependencies(vec![
155 CoreServiceDependency::Jobs,
156 CoreServiceDependency::Observability,
157 ])
158 .with_behaviors(vec![ModuleBehavior::AsyncJobs])
159 }
160
161 fn register(&self, registry: &mut ServiceRegistry) -> Result<(), RegistrationError> {
162 registry.register_module_service(
163 STRIPE_MODULE_NAME.to_string(),
164 STRIPE_SERVICE_ID,
165 "Stripe checkout handoff contract, publishable-key configuration, and signed webhook reconciliation for commerce checkout",
166 )
167 }
168}
169
170fn module_string_setting(table: &toml::Table, field: &str) -> Result<String, CommerceModelError> {
171 let value = table
172 .get(field)
173 .ok_or_else(|| CommerceModelError::MissingModuleSetting {
174 module: STRIPE_MODULE_NAME.to_string(),
175 field: field.to_string(),
176 })?;
177 let value = value
178 .as_str()
179 .ok_or_else(|| CommerceModelError::InvalidModuleSetting {
180 module: STRIPE_MODULE_NAME.to_string(),
181 field: field.to_string(),
182 reason: "expected a non-empty string".to_string(),
183 })?;
184 let value = value.trim();
185 if value.is_empty() {
186 return Err(CommerceModelError::InvalidModuleSetting {
187 module: STRIPE_MODULE_NAME.to_string(),
188 field: field.to_string(),
189 reason: "expected a non-empty string".to_string(),
190 });
191 }
192 Ok(value.to_string())
193}
194
195fn module_secret_setting(
196 table: &toml::Table,
197 field: &str,
198) -> Result<SecretRef, CommerceModelError> {
199 let value = table
200 .get(field)
201 .ok_or_else(|| CommerceModelError::MissingModuleSetting {
202 module: STRIPE_MODULE_NAME.to_string(),
203 field: field.to_string(),
204 })?;
205 value.clone().try_into().map_err(|error: toml::de::Error| {
206 CommerceModelError::InvalidModuleSetting {
207 module: STRIPE_MODULE_NAME.to_string(),
208 field: field.to_string(),
209 reason: error.to_string(),
210 }
211 })
212}