Skip to main content

cratefield_adapter_polar/
lib.rs

1//! `cratefield-adapter-polar`: the [`Payments`] port over the
2//! [Polar](https://polar.sh) API, a Merchant of Record (issue #690). Polar is
3//! the legal seller: it collects the payment, handles VAT, GST and sales tax,
4//! and pays the founder out, so a venture with no company yet can sell
5//! worldwide. Switching to `cratefield-adapter-stripe` later is a change of
6//! composition, not of module code (ADR 0027).
7//!
8//! It uses the runtime's [`HttpClient`] and [`Clock`] ports, no vendor SDK and
9//! no `reqwest`, so the same adapter runs on Workers and natively.
10//!
11//! **What maps and what does not.**
12//!
13//! | `Payments` | Polar |
14//! |---|---|
15//! | `create_subscription_checkout` | `POST /v1/checkouts/` for one product (monthly or annual) |
16//! | `create_checkout` | `POST /v1/checkouts/` with an ad-hoc price on the configured one-off product |
17//! | `create_portal_session` | `POST /v1/customer-sessions/` → `customer_portal_url` |
18//! | `refund` | `POST /v1/refunds/` (full or partial, with a reason), idempotent by metadata |
19//! | `report_usage` | `POST /v1/events/ingest`, deduplicated on `external_id` |
20//! | `get_dispute` / `list_disputes` / `close_dispute` | `/v1/disputes/` |
21//! | `verify_webhook` / `verify_webhook_request` | Standard Webhooks, both of Polar's key derivations |
22//! | `create_connect_account_link`, `charge_with_transfer` | [`PaymentsError::Unsupported`] |
23//!
24//! **Card data never crosses this adapter**: every call names a Polar object
25//! by id or returns a hosted Polar URL.
26//!
27//! **Secrets.** The organization access token and the webhook secret come in
28//! through [`Polar::new`] (read them from the Workers `Env` or the secrets
29//! layer), [`Polar::from_config`] or [`Polar::from_env`]. Neither is ever
30//! logged, formatted by `Debug`, or put in an error.
31#![doc = include_str!("../README.md")]
32#![forbid(unsafe_code)]
33
34use std::borrow::Cow;
35use std::collections::BTreeMap;
36use std::fmt;
37use std::sync::Arc;
38
39use async_trait::async_trait;
40use bytes::Bytes;
41use cratefield_core::{
42    Charge, CheckoutRequest, CheckoutSession, Clock, Config, ConnectAccountLink,
43    ConnectAccountLinkRequest, Dispute, DisputeListRequest, DisputePage, DisputeStatus, HttpClient,
44    Money, Payments, PaymentsError, PortalSession, PortalSessionRequest, Refund, RefundRequest,
45    SignatureScheme, SignedDelivery, SubscriptionCheckoutRequest, Svix, TransferCharge,
46    UsageReport, UsageReported, WebhookEvent, WebhookVerifier,
47};
48use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
49use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode};
50use serde_json::{Map, Value, json};
51use time::OffsetDateTime;
52use time::format_description::well_known::Rfc3339;
53
54mod normalize;
55
56pub use normalize::{
57    CheckoutChange, CheckoutEvent, CustomerChange, CustomerEvent, OrderChange, OrderEvent,
58    PolarEvent, RefundChange, RefundEvent, SubscriptionChange, SubscriptionEvent, normalize,
59};
60
61/// Polar's production API host.
62pub const PRODUCTION_BASE_URL: &str = "https://api.polar.sh";
63/// Polar's sandbox API host: isolated data, users, tokens and organizations.
64pub const SANDBOX_BASE_URL: &str = "https://sandbox-api.polar.sh";
65
66/// The webhook replay tolerance in seconds, either way: the Standard Webhooks
67/// recommendation and the harness default.
68pub const WEBHOOK_TOLERANCE_SECS: i64 = cratefield_core::DEFAULT_TOLERANCE_SECS;
69
70/// How many usage events go in one `POST /v1/events/ingest`. Polar documents
71/// no batch ceiling; this is our own bound on one request's size.
72pub const MAX_INGEST_BATCH: usize = 100;
73
74/// The metadata key a refund's idempotency key is stored under, so a retried
75/// [`Payments::refund`] finds the refund it already made instead of making a
76/// second one (Polar's API takes no `Idempotency-Key`).
77pub const REFUND_IDEMPOTENCY_KEY: &str = "cratefield_idempotency_key";
78
79/// Which Polar environment the adapter talks to. The two are fully isolated:
80/// a sandbox token does not work in production, and vice versa.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82pub enum Environment {
83    /// `https://sandbox-api.polar.sh`, the default: a missing setting can
84    /// never move real money.
85    #[default]
86    Sandbox,
87    /// `https://api.polar.sh`.
88    Production,
89}
90
91impl Environment {
92    /// The API host for this environment.
93    #[must_use]
94    pub const fn base_url(self) -> &'static str {
95        match self {
96            Self::Sandbox => SANDBOX_BASE_URL,
97            Self::Production => PRODUCTION_BASE_URL,
98        }
99    }
100
101    /// Reads `production` or `sandbox` (case-insensitive); anything else,
102    /// including nothing, is the sandbox.
103    #[must_use]
104    pub fn parse(value: Option<&str>) -> Self {
105        match value.map(str::trim) {
106            Some(v) if v.eq_ignore_ascii_case("production") => Self::Production,
107            _ => Self::Sandbox,
108        }
109    }
110}
111
112/// What a `customer_ref` names, everywhere the port takes or returns one.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
114pub enum CustomerIds {
115    /// Polar's own customer id, the same meaning `adapter-stripe` gives a
116    /// Stripe customer id.
117    #[default]
118    Polar,
119    /// The venture's own id (Polar's `external_customer_id`), typically the
120    /// account id: the venture never has to store a Polar customer id, and
121    /// a checkout creates the Polar customer with that external id.
122    External,
123}
124
125/// The reason Polar records on a refund (`RefundCreate.reason`).
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
127pub enum RefundReason {
128    Duplicate,
129    Fraudulent,
130    #[default]
131    CustomerRequest,
132    ServiceDisruption,
133    SatisfactionGuarantee,
134    Other,
135}
136
137impl RefundReason {
138    /// Polar's spelling.
139    #[must_use]
140    pub const fn as_str(self) -> &'static str {
141        match self {
142            Self::Duplicate => "duplicate",
143            Self::Fraudulent => "fraudulent",
144            Self::CustomerRequest => "customer_request",
145            Self::ServiceDisruption => "service_disruption",
146            Self::SatisfactionGuarantee => "satisfaction_guarantee",
147            Self::Other => "other",
148        }
149    }
150}
151
152/// The outcome of [`Polar::report_usage_batch`]: Polar answers counts, not
153/// per-event results.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
155pub struct UsageBatchReported {
156    /// Events Polar stored.
157    pub inserted: u64,
158    /// Events Polar skipped because their `external_id` was already ingested.
159    pub duplicates: u64,
160}
161
162/// [`Payments`] over the Polar API.
163pub struct Polar {
164    inner: Inner,
165}
166
167enum Inner {
168    Live(Box<Live>),
169    NotConfigured,
170}
171
172struct Live {
173    http: Arc<dyn HttpClient>,
174    clock: Arc<dyn Clock>,
175    /// The organization access token (`polar_oat_…`). Never logged.
176    token: String,
177    /// The webhook endpoint secret. Never logged; may be empty when webhooks
178    /// are not set up yet (then verification reports `NotConfigured`).
179    webhook_secret: String,
180    base_url: String,
181    customers: CustomerIds,
182    one_off_product: Option<String>,
183    usage_value_key: String,
184    refund_reason: RefundReason,
185}
186
187impl fmt::Debug for Polar {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        match &self.inner {
190            Inner::Live(live) => f
191                .debug_struct("Polar")
192                .field("base_url", &live.base_url)
193                .field("customers", &live.customers)
194                .field("one_off_product", &live.one_off_product)
195                .field("token", &"<redacted>")
196                .field("webhook_secret", &"<redacted>")
197                .finish_non_exhaustive(),
198            Inner::NotConfigured => f.write_str("Polar(NotConfigured)"),
199        }
200    }
201}
202
203impl Polar {
204    /// A live adapter against `environment`. `access_token` is an
205    /// organization access token; a blank one makes the adapter
206    /// [`NotConfigured`](PaymentsError::NotConfigured) with no network call.
207    /// `webhook_secret` is the endpoint secret from the Polar dashboard (pass
208    /// an empty string while webhooks are not set up).
209    #[must_use]
210    pub fn new(
211        http: Arc<dyn HttpClient>,
212        clock: Arc<dyn Clock>,
213        environment: Environment,
214        access_token: impl Into<String>,
215        webhook_secret: impl Into<String>,
216    ) -> Self {
217        let token = access_token.into();
218        if token.trim().is_empty() {
219            return Self::not_configured();
220        }
221        Self {
222            inner: Inner::Live(Box::new(Live {
223                http,
224                clock,
225                token: token.trim().to_owned(),
226                webhook_secret: webhook_secret.into(),
227                base_url: environment.base_url().to_owned(),
228                customers: CustomerIds::default(),
229                one_off_product: None,
230                usage_value_key: "value".to_owned(),
231                refund_reason: RefundReason::default(),
232            })),
233        }
234    }
235
236    /// A degraded adapter that reports [`PaymentsError::NotConfigured`]
237    /// without any network call.
238    #[must_use]
239    pub fn not_configured() -> Self {
240        Self {
241            inner: Inner::NotConfigured,
242        }
243    }
244
245    /// Reads `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET` and
246    /// `POLAR_ENVIRONMENT` (`production` or `sandbox`, default sandbox) from
247    /// `config` — the harness config, which the runtimes fill from secrets.
248    #[must_use]
249    pub fn from_config(
250        http: Arc<dyn HttpClient>,
251        clock: Arc<dyn Clock>,
252        config: &dyn Config,
253    ) -> Self {
254        Self::new(
255            http,
256            clock,
257            Environment::parse(config.get("POLAR_ENVIRONMENT").as_deref()),
258            config.get("POLAR_ACCESS_TOKEN").unwrap_or_default(),
259            config.get("POLAR_WEBHOOK_SECRET").unwrap_or_default(),
260        )
261    }
262
263    /// [`Polar::from_config`] over the process environment (the native
264    /// runtime). On Workers read the secrets from the `Env` and use
265    /// [`Polar::new`].
266    #[must_use]
267    pub fn from_env(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
268        Self::new(
269            http,
270            clock,
271            Environment::parse(std::env::var("POLAR_ENVIRONMENT").ok().as_deref()),
272            std::env::var("POLAR_ACCESS_TOKEN").unwrap_or_default(),
273            std::env::var("POLAR_WEBHOOK_SECRET").unwrap_or_default(),
274        )
275    }
276
277    /// Overrides the API host (a proxy, or a test double's expected host).
278    #[must_use]
279    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
280        if let Inner::Live(live) = &mut self.inner {
281            base_url
282                .into()
283                .trim_end_matches('/')
284                .clone_into(&mut live.base_url);
285        }
286        self
287    }
288
289    /// What a `customer_ref` names (default: Polar's customer id).
290    #[must_use]
291    pub fn with_customer_ids(mut self, customers: CustomerIds) -> Self {
292        if let Inner::Live(live) = &mut self.inner {
293            live.customers = customers;
294        }
295        self
296    }
297
298    /// The Polar product a one-off [`Payments::create_checkout`] charges
299    /// against, with the request's amount as an ad-hoc price. Polar has no
300    /// checkout without a product; without this, one-off checkout is
301    /// [`PaymentsError::Unsupported`].
302    #[must_use]
303    pub fn with_one_off_product(mut self, product_id: impl Into<String>) -> Self {
304        if let Inner::Live(live) = &mut self.inner {
305            live.one_off_product = Some(product_id.into());
306        }
307        self
308    }
309
310    /// The event-metadata key [`UsageReport::value`] is ingested under
311    /// (default `value`). The Polar meter must sum this property.
312    #[must_use]
313    pub fn with_usage_value_key(mut self, key: impl Into<String>) -> Self {
314        if let Inner::Live(live) = &mut self.inner {
315            live.usage_value_key = key.into();
316        }
317        self
318    }
319
320    /// The reason [`Payments::refund`] records (default `customer_request`).
321    #[must_use]
322    pub fn with_refund_reason(mut self, reason: RefundReason) -> Self {
323        if let Inner::Live(live) = &mut self.inner {
324            live.refund_reason = reason;
325        }
326        self
327    }
328
329    fn live(&self) -> Result<&Live, PaymentsError> {
330        match &self.inner {
331            Inner::Live(live) => Ok(live),
332            Inner::NotConfigured => Err(PaymentsError::NotConfigured),
333        }
334    }
335
336    /// Maps a verified event onto [`PolarEvent`]s, naming customers the way
337    /// this adapter is configured to ([`Polar::with_customer_ids`]).
338    #[must_use]
339    pub fn normalize(&self, event: &WebhookEvent) -> Vec<PolarEvent> {
340        let customers = match &self.inner {
341            Inner::Live(live) => live.customers,
342            Inner::NotConfigured => CustomerIds::default(),
343        };
344        normalize(event, customers)
345    }
346
347    /// [`Payments::refund`] with an explicit reason. `request.amount` is the
348    /// **net** amount (before tax), as Polar refunds it; Polar refunds the
349    /// matching tax itself. `None` refunds the order's whole
350    /// `refundable_amount`.
351    ///
352    /// Idempotent on `request.idempotency_key`: the key is stored in the
353    /// refund's metadata and looked up first, so a retry returns the refund
354    /// already made.
355    ///
356    /// # Errors
357    ///
358    /// [`PaymentsError::Rejected`] for an order that has nothing left to
359    /// refund or a refusal; [`PaymentsError::Transient`] for `429`/`5xx`.
360    pub async fn refund_with_reason(
361        &self,
362        request: &RefundRequest,
363        reason: RefundReason,
364    ) -> Result<Refund, PaymentsError> {
365        let live = self.live()?;
366        let order_id = path_segment(&request.payment_ref)?;
367
368        // A retry after a success: the refund is already there.
369        let existing = live
370            .get_json(
371                "/v1/refunds/",
372                &[
373                    ("order_id", order_id.to_owned()),
374                    ("limit", "100".to_owned()),
375                ],
376            )
377            .await?;
378        if let Some(found) = items(&existing).iter().find(|refund| {
379            refund
380                .get("metadata")
381                .and_then(|m| m.get(REFUND_IDEMPOTENCY_KEY))
382                .and_then(Value::as_str)
383                == Some(request.idempotency_key.as_str())
384        }) {
385            return Ok(Refund {
386                id: string(found, "id")?.to_owned(),
387            });
388        }
389
390        let amount = if let Some(money) = &request.amount {
391            money.minor_units
392        } else {
393            let order = live
394                .get_json(&format!("/v1/orders/{order_id}"), &[])
395                .await?;
396            order
397                .get("refundable_amount")
398                .and_then(Value::as_i64)
399                .ok_or_else(|| missing("refundable_amount"))?
400        };
401        if amount <= 0 {
402            return Err(PaymentsError::Rejected(
403                "polar: the order has nothing left to refund".to_owned(),
404            ));
405        }
406
407        let body = json!({
408            "order_id": order_id,
409            "reason": reason.as_str(),
410            "amount": amount,
411            "metadata": { REFUND_IDEMPOTENCY_KEY: request.idempotency_key },
412        });
413        let refund = live
414            .send_json(Method::POST, "/v1/refunds/", &[], Some(&body))
415            .await?;
416        Ok(Refund {
417            id: string(&refund, "id")?.to_owned(),
418        })
419    }
420
421    /// Ingests many usage reports, in requests of at most
422    /// [`MAX_INGEST_BATCH`] events. Each report's `identifier` is the event's
423    /// `external_id`, so re-sending a report Polar has already counted is a
424    /// duplicate, not a second count.
425    ///
426    /// # Errors
427    ///
428    /// As [`Payments::report_usage`]. A failure part-way leaves the earlier
429    /// chunks ingested; re-sending the whole batch is safe.
430    pub async fn report_usage_batch(
431        &self,
432        reports: &[UsageReport],
433    ) -> Result<UsageBatchReported, PaymentsError> {
434        let live = self.live()?;
435        for report in reports {
436            validate_usage(report)?;
437        }
438        let mut total = UsageBatchReported::default();
439        for chunk in reports.chunks(MAX_INGEST_BATCH) {
440            let counts = live.ingest(chunk).await?;
441            total.inserted += counts.inserted;
442            total.duplicates += counts.duplicates;
443        }
444        Ok(total)
445    }
446}
447
448impl Live {
449    fn url(&self, path: &str, query: &[(&str, String)]) -> String {
450        let mut url = format!("{}{path}", self.base_url);
451        for (index, (key, value)) in query.iter().enumerate() {
452            url.push(if index == 0 { '?' } else { '&' });
453            url.push_str(&percent_encode(key));
454            url.push('=');
455            url.push_str(&percent_encode(value));
456        }
457        url
458    }
459
460    /// One request; returns the parsed JSON on `2xx`, else a mapped error.
461    async fn send_json(
462        &self,
463        method: Method,
464        path: &str,
465        query: &[(&str, String)],
466        body: Option<&Value>,
467    ) -> Result<Value, PaymentsError> {
468        let (status, bytes) = self.send_raw(method, path, query, body).await?;
469        if !status.is_success() {
470            return Err(map_error(status, &bytes));
471        }
472        serde_json::from_slice(&bytes)
473            .map_err(|err| PaymentsError::Rejected(format!("unparseable Polar response: {err}")))
474    }
475
476    async fn get_json(&self, path: &str, query: &[(&str, String)]) -> Result<Value, PaymentsError> {
477        self.send_json(Method::GET, path, query, None).await
478    }
479
480    async fn send_raw(
481        &self,
482        method: Method,
483        path: &str,
484        query: &[(&str, String)],
485        body: Option<&Value>,
486    ) -> Result<(StatusCode, Bytes), PaymentsError> {
487        let mut builder = Request::builder()
488            .method(method)
489            .uri(self.url(path, query))
490            .header(AUTHORIZATION, format!("Bearer {}", self.token))
491            .header(ACCEPT, "application/json");
492        let payload = match body {
493            Some(body) => {
494                builder = builder.header(CONTENT_TYPE, "application/json");
495                Bytes::from(body.to_string())
496            }
497            None => Bytes::new(),
498        };
499        // The builder error names the URI or a header; never the token.
500        let request = builder
501            .body(payload)
502            .map_err(|_| PaymentsError::Rejected("could not build the Polar request".to_owned()))?;
503        let response = self
504            .http
505            .send(request)
506            .await
507            .map_err(|err| PaymentsError::Transient(err.to_string()))?;
508        Ok((response.status(), response.into_body()))
509    }
510
511    fn customer_field(&self) -> &'static str {
512        match self.customers {
513            CustomerIds::Polar => "customer_id",
514            CustomerIds::External => "external_customer_id",
515        }
516    }
517
518    async fn ingest(&self, reports: &[UsageReport]) -> Result<UsageBatchReported, PaymentsError> {
519        let customer_field = self.customer_field();
520        let events: Vec<Value> = reports
521            .iter()
522            .map(|report| {
523                let mut event = Map::new();
524                event.insert("name".to_owned(), json!(report.meter_event_name));
525                event.insert(customer_field.to_owned(), json!(report.customer_ref));
526                event.insert("external_id".to_owned(), json!(report.identifier));
527                event.insert("timestamp".to_owned(), json!(rfc3339(report.timestamp)));
528                event.insert(
529                    "metadata".to_owned(),
530                    json!({ self.usage_value_key.as_str(): report.value }),
531                );
532                Value::Object(event)
533            })
534            .collect();
535        let response = self
536            .send_json(
537                Method::POST,
538                "/v1/events/ingest",
539                &[],
540                Some(&json!({ "events": events })),
541            )
542            .await?;
543        Ok(UsageBatchReported {
544            inserted: response
545                .get("inserted")
546                .and_then(Value::as_u64)
547                .unwrap_or(0),
548            duplicates: response
549                .get("duplicates")
550                .and_then(Value::as_u64)
551                .unwrap_or(0),
552        })
553    }
554
555    async fn checkout(&self, body: Value) -> Result<CheckoutSession, PaymentsError> {
556        let object = self
557            .send_json(Method::POST, "/v1/checkouts/", &[], Some(&body))
558            .await?;
559        Ok(CheckoutSession {
560            id: string(&object, "id")?.to_owned(),
561            url: string(&object, "url")?.to_owned(),
562        })
563    }
564
565    /// The fields every checkout shares: who, where to, and the metadata.
566    fn checkout_base(
567        &self,
568        product_id: &str,
569        customer_ref: Option<&str>,
570        customer_email: Option<&str>,
571        success_url: &str,
572        cancel_url: &str,
573        metadata: &BTreeMap<String, String>,
574    ) -> Map<String, Value> {
575        let mut body = Map::new();
576        body.insert("products".to_owned(), json!([product_id]));
577        if let Some(customer) = customer_ref {
578            body.insert(self.customer_field().to_owned(), json!(customer));
579        }
580        if let Some(email) = customer_email {
581            body.insert("customer_email".to_owned(), json!(email));
582        }
583        body.insert("success_url".to_owned(), json!(success_url));
584        // Polar shows a back button to `return_url`: the cancel path.
585        body.insert("return_url".to_owned(), json!(cancel_url));
586        // Copied by Polar onto the resulting order and subscription, so it
587        // comes back on their webhooks.
588        body.insert("metadata".to_owned(), json!(metadata));
589        body
590    }
591}
592
593#[async_trait]
594impl Payments for Polar {
595    async fn create_checkout(
596        &self,
597        request: &CheckoutRequest,
598    ) -> Result<CheckoutSession, PaymentsError> {
599        let live = self.live()?;
600        let Some(product) = live.one_off_product.as_deref() else {
601            return Err(PaymentsError::Unsupported(
602                "one-off checkout on Polar without a configured product (with_one_off_product)",
603            ));
604        };
605        let total = request
606            .line
607            .amount
608            .minor_units
609            .checked_mul(i64::from(request.line.quantity))
610            .ok_or_else(|| PaymentsError::Rejected("checkout amount overflows".to_owned()))?;
611        let mut body = live.checkout_base(
612            product,
613            request.customer_ref.as_deref(),
614            request.customer_email.as_deref(),
615            &request.success_url,
616            &request.cancel_url,
617            &request.metadata,
618        );
619        // Polar has no free-form line items: the line becomes an ad-hoc fixed
620        // price on the one-off product, for this checkout only.
621        body.insert(
622            "prices".to_owned(),
623            json!({ product: [{
624                "amount_type": "fixed",
625                "price_amount": total,
626                "price_currency": request.line.amount.currency.to_ascii_lowercase(),
627            }] }),
628        );
629        live.checkout(Value::Object(body)).await
630    }
631
632    async fn create_subscription_checkout(
633        &self,
634        request: &SubscriptionCheckoutRequest,
635    ) -> Result<CheckoutSession, PaymentsError> {
636        let live = self.live()?;
637        // A Polar product carries one recurring interval, so a monthly and an
638        // annual plan are two products: `price_ref` is the product id.
639        let mut body = live.checkout_base(
640            &request.price_ref,
641            request.customer_ref.as_deref(),
642            request.customer_email.as_deref(),
643            &request.success_url,
644            &request.cancel_url,
645            &request.metadata,
646        );
647        if let Some(days) = request.trial_days {
648            body.insert("allow_trial".to_owned(), json!(true));
649            body.insert("trial_interval".to_owned(), json!("day"));
650            body.insert("trial_interval_count".to_owned(), json!(days));
651        }
652        live.checkout(Value::Object(body)).await
653    }
654
655    async fn create_connect_account_link(
656        &self,
657        _request: &ConnectAccountLinkRequest,
658    ) -> Result<ConnectAccountLink, PaymentsError> {
659        self.live()?;
660        Err(PaymentsError::Unsupported(
661            "Connect onboarding: Polar is the merchant of record and pays out the seller itself",
662        ))
663    }
664
665    async fn charge_with_transfer(
666        &self,
667        _request: &TransferCharge,
668    ) -> Result<Charge, PaymentsError> {
669        self.live()?;
670        Err(PaymentsError::Unsupported(
671            "destination charges: Polar has no server-side charge or transfer API",
672        ))
673    }
674
675    async fn refund(&self, request: &RefundRequest) -> Result<Refund, PaymentsError> {
676        let reason = self.live()?.refund_reason;
677        self.refund_with_reason(request, reason).await
678    }
679
680    /// `signature_header` is the three Standard Webhooks headers packed one
681    /// per line as `name: value` — what [`signature_header`] builds from a
682    /// request. Prefer [`Payments::verify_webhook_request`], which takes the
683    /// headers directly.
684    async fn verify_webhook(
685        &self,
686        signature_header: &str,
687        body: &[u8],
688    ) -> Result<WebhookEvent, PaymentsError> {
689        let headers = unpack_signature_header(signature_header);
690        self.verify_webhook_request(&headers, body).await
691    }
692
693    async fn verify_webhook_request(
694        &self,
695        headers: &HeaderMap,
696        body: &[u8],
697    ) -> Result<WebhookEvent, PaymentsError> {
698        let live = self.live()?;
699        if live.webhook_secret.trim().is_empty() {
700            return Err(PaymentsError::NotConfigured);
701        }
702        let now = live.clock.now().unix_timestamp();
703        // Both of Polar's key derivations are tried, the way Polar's own SDKs
704        // do: a secret made before 2026-09-08 keys the HMAC with its UTF-8
705        // bytes ("Polar HMAC"), a newer one is a Standard Webhooks secret
706        // (base64 after `whsec_`). Each check is constant-time; both always
707        // run, so timing does not say which key a secret is.
708        let standard = WebhookVerifier::new(Svix).verify(&live.webhook_secret, headers, body, now);
709        let legacy =
710            WebhookVerifier::new(PolarHmac).verify(&live.webhook_secret, headers, body, now);
711        if !(standard | legacy) {
712            return Err(PaymentsError::SignatureInvalid(
713                "no signature matched, or the timestamp is outside tolerance".to_owned(),
714            ));
715        }
716
717        // Standard Webhooks: the message id is the header, stable across
718        // Polar's retries, and the dedup key for the `Inbox`.
719        let id = headers
720            .get("webhook-id")
721            .or_else(|| headers.get("svix-id"))
722            .and_then(|value| value.to_str().ok())
723            .ok_or_else(|| PaymentsError::SignatureInvalid("no webhook-id header".to_owned()))?;
724        let event: Value = serde_json::from_slice(body)
725            .map_err(|err| PaymentsError::SignatureInvalid(format!("unparseable event: {err}")))?;
726        let kind = event
727            .get("type")
728            .and_then(Value::as_str)
729            .ok_or_else(|| PaymentsError::SignatureInvalid("event has no `type`".to_owned()))?;
730        Ok(WebhookEvent {
731            id: id.to_owned(),
732            kind: kind.to_owned(),
733            data: event.get("data").cloned().unwrap_or(Value::Null),
734        })
735    }
736
737    async fn report_usage(&self, report: &UsageReport) -> Result<UsageReported, PaymentsError> {
738        let live = self.live()?;
739        validate_usage(report)?;
740        let counts = live.ingest(std::slice::from_ref(report)).await?;
741        if counts.inserted == 0 && counts.duplicates == 0 {
742            return Err(PaymentsError::Rejected(
743                "polar ingested neither the event nor a duplicate of it".to_owned(),
744            ));
745        }
746        Ok(UsageReported {
747            identifier: report.identifier.clone(),
748            already_reported: counts.inserted == 0,
749        })
750    }
751
752    async fn create_portal_session(
753        &self,
754        request: &PortalSessionRequest,
755    ) -> Result<PortalSession, PaymentsError> {
756        let live = self.live()?;
757        if request.customer_ref.trim().is_empty() {
758            return Err(PaymentsError::Rejected(
759                "a portal session needs a customer".to_owned(),
760            ));
761        }
762        let body = json!({
763            live.customer_field(): request.customer_ref,
764            "return_url": request.return_url,
765        });
766        let session = live
767            .send_json(Method::POST, "/v1/customer-sessions/", &[], Some(&body))
768            .await?;
769        Ok(PortalSession {
770            url: string(&session, "customer_portal_url")?.to_owned(),
771        })
772    }
773
774    async fn get_dispute(&self, dispute_ref: &str) -> Result<Dispute, PaymentsError> {
775        let live = self.live()?;
776        let id = path_segment(dispute_ref)?;
777        let object = live.get_json(&format!("/v1/disputes/{id}"), &[]).await?;
778        dispute_from(&object, live.customers)
779    }
780
781    async fn list_disputes(
782        &self,
783        request: &DisputeListRequest,
784    ) -> Result<DisputePage, PaymentsError> {
785        let live = self.live()?;
786        let page: u64 = match &request.cursor {
787            Some(cursor) => cursor
788                .parse()
789                .map_err(|_| PaymentsError::Rejected("not a Polar dispute cursor".to_owned()))?,
790            None => 1,
791        };
792        let mut query = vec![
793            ("page", page.to_string()),
794            ("limit", "100".to_owned()),
795            ("sorting", "-created_at".to_owned()),
796        ];
797        if request.open_only {
798            for status in ["early_warning", "needs_response", "under_review"] {
799                query.push(("status", status.to_owned()));
800            }
801        }
802        let list = live.get_json("/v1/disputes/", &query).await?;
803        let disputes = items(&list)
804            .iter()
805            .map(|object| dispute_from(object, live.customers))
806            .collect::<Result<Vec<_>, _>>()?;
807        let max_page = list
808            .get("pagination")
809            .and_then(|p| p.get("max_page"))
810            .and_then(Value::as_u64)
811            .unwrap_or(page);
812        Ok(DisputePage {
813            disputes,
814            next: (page < max_page).then(|| (page + 1).to_string()),
815        })
816    }
817
818    /// Polar's "accept": concedes the chargeback, settling it as lost. Polar
819    /// takes no idempotency key; accepting a dispute that is no longer open
820    /// is a `409`, reported as [`PaymentsError::Rejected`].
821    async fn close_dispute(
822        &self,
823        dispute_ref: &str,
824        _idempotency_key: &str,
825    ) -> Result<Dispute, PaymentsError> {
826        let live = self.live()?;
827        let id = path_segment(dispute_ref)?;
828        let object = live
829            .send_json(
830                Method::POST,
831                &format!("/v1/disputes/{id}/accept"),
832                &[],
833                None,
834            )
835            .await?;
836        dispute_from(&object, live.customers)
837    }
838}
839
840/// The older "Polar HMAC" key derivation: the Standard Webhooks layout
841/// (`{id}.{timestamp}.{body}`, `v1,<base64>`), keyed with the secret's own
842/// UTF-8 bytes instead of its base64 decoding.
843struct PolarHmac;
844
845impl SignatureScheme for PolarHmac {
846    fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
847        Svix.extract(headers, body)
848    }
849
850    fn secret_key<'a>(&self, secret: &'a str) -> Option<Cow<'a, [u8]>> {
851        let trimmed = secret.trim();
852        (!trimmed.is_empty()).then_some(Cow::Borrowed(trimmed.as_bytes()))
853    }
854}
855
856/// The three Standard Webhooks headers a Polar delivery carries.
857const SIGNATURE_HEADERS: [&str; 3] = ["webhook-id", "webhook-timestamp", "webhook-signature"];
858
859/// Packs a delivery's `webhook-id`, `webhook-timestamp` and
860/// `webhook-signature` into the one string [`Payments::verify_webhook`]
861/// takes, one `name: value` per line (header values cannot hold a newline,
862/// so the packing is unambiguous). `None` when one is missing.
863#[must_use]
864pub fn signature_header(headers: &HeaderMap) -> Option<String> {
865    let mut lines = Vec::with_capacity(SIGNATURE_HEADERS.len());
866    for name in SIGNATURE_HEADERS {
867        let value = headers.get(name)?.to_str().ok()?;
868        lines.push(format!("{name}: {value}"));
869    }
870    Some(lines.join("\n"))
871}
872
873fn unpack_signature_header(packed: &str) -> HeaderMap {
874    let mut headers = HeaderMap::new();
875    for line in packed.lines() {
876        let Some((name, value)) = line.split_once(':') else {
877            continue;
878        };
879        let name = name.trim().to_ascii_lowercase();
880        if !SIGNATURE_HEADERS.contains(&name.as_str()) {
881            continue;
882        }
883        if let (Ok(name), Ok(value)) = (
884            HeaderName::from_bytes(name.as_bytes()),
885            HeaderValue::from_str(value.trim()),
886        ) {
887            headers.insert(name, value);
888        }
889    }
890    headers
891}
892
893/// Maps one Polar dispute object onto the port's [`Dispute`].
894pub(crate) fn dispute_from(
895    object: &Value,
896    customers: CustomerIds,
897) -> Result<Dispute, PaymentsError> {
898    let provider_status = string(object, "status")?.to_owned();
899    let customer = object.get("customer");
900    let customer_ref = match customers {
901        CustomerIds::Polar => customer
902            .and_then(|c| c.get("id"))
903            .and_then(Value::as_str)
904            .map(str::to_owned),
905        CustomerIds::External => customer
906            .and_then(|c| c.get("external_id"))
907            .and_then(Value::as_str)
908            .map(str::to_owned),
909    };
910    Ok(Dispute {
911        id: string(object, "id")?.to_owned(),
912        charge_ref: optional_string(object, "payment_id"),
913        payment_ref: string(object, "order_id")?.to_owned(),
914        customer_ref,
915        amount: Money::new(
916            object
917                .get("amount")
918                .and_then(Value::as_i64)
919                .ok_or_else(|| missing("amount"))?,
920            string(object, "currency")?,
921        ),
922        reason: optional_string(object, "reason"),
923        status: DisputeStatus::from_provider(&provider_status),
924        provider_status,
925        evidence_due_by: optional_time(object, "evidence_due_by"),
926        is_charge_refundable: None,
927        balance_transactions: Vec::new(),
928    })
929}
930
931fn validate_usage(report: &UsageReport) -> Result<(), PaymentsError> {
932    // A shape error is the caller's: refuse before any network call.
933    if report.identifier.is_empty()
934        || report.meter_event_name.is_empty()
935        || report.customer_ref.is_empty()
936    {
937        return Err(PaymentsError::Rejected(
938            "usage report needs a non-empty identifier, meter event name and customer".to_owned(),
939        ));
940    }
941    Ok(())
942}
943
944/// Polar's error detail: `detail` as a string, or the first validation
945/// message of a `422`; the response body never contains our token.
946fn error_detail(body: &[u8]) -> Option<String> {
947    let value: Value = serde_json::from_slice(body).ok()?;
948    let error = value.get("error").and_then(Value::as_str);
949    let detail = match value.get("detail") {
950        Some(Value::String(detail)) => Some(detail.clone()),
951        Some(Value::Array(list)) => list
952            .first()
953            .and_then(|first| first.get("msg"))
954            .and_then(Value::as_str)
955            .map(str::to_owned),
956        _ => None,
957    };
958    match (error, detail) {
959        (Some(error), Some(detail)) => Some(format!("{error}: {detail}")),
960        (Some(error), None) => Some(error.to_owned()),
961        (None, detail) => detail,
962    }
963}
964
965/// `429`/`5xx` are retryable; everything else will not succeed unchanged.
966fn map_error(status: StatusCode, body: &[u8]) -> PaymentsError {
967    let detail = error_detail(body).unwrap_or_else(|| status.as_u16().to_string());
968    let message = format!("polar {}: {detail}", status.as_u16());
969    if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
970        PaymentsError::Transient(message)
971    } else {
972        PaymentsError::Rejected(message)
973    }
974}
975
976fn missing(key: &str) -> PaymentsError {
977    PaymentsError::Rejected(format!("Polar response missing `{key}`"))
978}
979
980pub(crate) fn string<'a>(object: &'a Value, key: &str) -> Result<&'a str, PaymentsError> {
981    object
982        .get(key)
983        .and_then(Value::as_str)
984        .ok_or_else(|| missing(key))
985}
986
987pub(crate) fn optional_string(object: &Value, key: &str) -> Option<String> {
988    object.get(key).and_then(Value::as_str).map(str::to_owned)
989}
990
991pub(crate) fn optional_time(object: &Value, key: &str) -> Option<OffsetDateTime> {
992    object
993        .get(key)
994        .and_then(Value::as_str)
995        .and_then(|text| OffsetDateTime::parse(text, &Rfc3339).ok())
996}
997
998fn items(list: &Value) -> &[Value] {
999    list.get("items")
1000        .and_then(Value::as_array)
1001        .map_or(&[], Vec::as_slice)
1002}
1003
1004fn rfc3339(at: OffsetDateTime) -> String {
1005    at.to_offset(time::UtcOffset::UTC)
1006        .format(&Rfc3339)
1007        .unwrap_or_default()
1008}
1009
1010/// An id that goes into a URL path: Polar ids are UUIDs, so anything outside
1011/// `[A-Za-z0-9_-]` is refused rather than allowed to change the path.
1012fn path_segment(id: &str) -> Result<&str, PaymentsError> {
1013    if !id.is_empty()
1014        && id
1015            .bytes()
1016            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1017    {
1018        Ok(id)
1019    } else {
1020        Err(PaymentsError::Rejected("not a Polar id".to_owned()))
1021    }
1022}
1023
1024/// Percent-encodes a query component: unreserved bytes pass through.
1025fn percent_encode(input: &str) -> String {
1026    let mut out = String::with_capacity(input.len());
1027    for &byte in input.as_bytes() {
1028        match byte {
1029            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1030                out.push(byte as char);
1031            }
1032            other => {
1033                use std::fmt::Write as _;
1034                let _ = write!(out, "%{other:02X}");
1035            }
1036        }
1037    }
1038    out
1039}