Skip to main content

coil_commerce/module/
stripe.rs

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}