1#![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
61pub const PRODUCTION_BASE_URL: &str = "https://api.polar.sh";
63pub const SANDBOX_BASE_URL: &str = "https://sandbox-api.polar.sh";
65
66pub const WEBHOOK_TOLERANCE_SECS: i64 = cratefield_core::DEFAULT_TOLERANCE_SECS;
69
70pub const MAX_INGEST_BATCH: usize = 100;
73
74pub const REFUND_IDEMPOTENCY_KEY: &str = "cratefield_idempotency_key";
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82pub enum Environment {
83 #[default]
86 Sandbox,
87 Production,
89}
90
91impl Environment {
92 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
114pub enum CustomerIds {
115 #[default]
118 Polar,
119 External,
123}
124
125#[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
155pub struct UsageBatchReported {
156 pub inserted: u64,
158 pub duplicates: u64,
160}
161
162pub 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 token: String,
177 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 #[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 #[must_use]
239 pub fn not_configured() -> Self {
240 Self {
241 inner: Inner::NotConfigured,
242 }
243 }
244
245 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 body.insert("return_url".to_owned(), json!(cancel_url));
586 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 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 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 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 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 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 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
840struct 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
856const SIGNATURE_HEADERS: [&str; 3] = ["webhook-id", "webhook-timestamp", "webhook-signature"];
858
859#[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
893pub(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 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
944fn 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
965fn 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
1010fn 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
1024fn 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}