Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 123 additions & 12 deletions crates/starstats-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,12 @@ impl RoadmapPipelineConfig {

/// Revolut Business Merchant API client configuration.
///
/// Required env vars:
/// - `REVOLUT_API_KEY` Bearer key from the merchant dashboard.
/// - `REVOLUT_WEBHOOK_SECRET` HMAC signing secret for webhook events.
/// Required env vars (each accepts either the value inline OR a `_FILE`
/// variant pointing at a Docker-secrets-style mount; matches the
/// convention used by `MINIO_SECRET_KEY_FILE`, `SPICEDB_PRESHARED_KEY_FILE`,
/// `ROADMAP_GH_*_FILE` etc.):
/// - `REVOLUT_API_KEY` (or `_FILE`) Bearer key from the merchant dashboard.
/// - `REVOLUT_WEBHOOK_SECRET` (or `_FILE`) HMAC signing secret for webhook events.
///
/// Optional env vars (sensible defaults shipped):
/// - `REVOLUT_API_BASE` Defaults to the sandbox host. Switch to
Expand All @@ -168,12 +171,16 @@ pub struct RevolutConfig {
}

impl RevolutConfig {
pub fn from_env() -> Option<Self> {
let api_key = std::env::var("REVOLUT_API_KEY").ok()?;
let webhook_secret = std::env::var("REVOLUT_WEBHOOK_SECRET").ok()?;
if api_key.is_empty() || webhook_secret.is_empty() {
return None;
}
pub fn from_env() -> Result<Option<Self>> {
let api_key = match read_env_or_file("REVOLUT_API_KEY", "REVOLUT_API_KEY_FILE")? {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
};
let webhook_secret =
match read_env_or_file("REVOLUT_WEBHOOK_SECRET", "REVOLUT_WEBHOOK_SECRET_FILE")? {
Some(v) if !v.is_empty() => v,
_ => return Ok(None),
};
let api_base = std::env::var("REVOLUT_API_BASE")
.unwrap_or_else(|_| "https://sandbox-merchant.revolut.com".to_string())
.trim_end_matches('/')
Expand All @@ -183,13 +190,13 @@ impl RevolutConfig {
let return_url = std::env::var("REVOLUT_RETURN_URL")
.ok()
.filter(|s| !s.is_empty());
Some(Self {
Ok(Some(Self {
api_key,
webhook_secret,
api_base,
api_version,
return_url,
})
}))
}
}

Expand Down Expand Up @@ -445,7 +452,7 @@ impl Config {
let smtp = SmtpConfig::from_env()?;
let updater = UpdaterConfig::from_env();
let kek = KekConfig::from_env();
let revolut = RevolutConfig::from_env();
let revolut = RevolutConfig::from_env()?;
if revolut.is_some() {
tracing::info!("Revolut Business merchant API configured");
} else {
Expand Down Expand Up @@ -754,4 +761,108 @@ mod tests {

clear_minio_env();
}

fn clear_revolut_env() {
std::env::remove_var("REVOLUT_API_KEY");
std::env::remove_var("REVOLUT_API_KEY_FILE");
std::env::remove_var("REVOLUT_WEBHOOK_SECRET");
std::env::remove_var("REVOLUT_WEBHOOK_SECRET_FILE");
std::env::remove_var("REVOLUT_API_BASE");
std::env::remove_var("REVOLUT_API_VERSION");
std::env::remove_var("REVOLUT_RETURN_URL");
}

#[test]
fn revolut_config_returns_none_when_unset() {
let _g = ENV_LOCK.lock().unwrap();
clear_revolut_env();
let cfg = RevolutConfig::from_env().unwrap();
assert!(cfg.is_none(), "missing keys should map to None (degraded)");
}

#[test]
fn revolut_config_returns_none_when_only_api_key_set() {
let _g = ENV_LOCK.lock().unwrap();
clear_revolut_env();
std::env::set_var("REVOLUT_API_KEY", "sk_test");
// No webhook secret -> still None.
let cfg = RevolutConfig::from_env().unwrap();
assert!(cfg.is_none());
clear_revolut_env();
}

#[test]
fn revolut_config_reads_inline_values_with_defaults() {
let _g = ENV_LOCK.lock().unwrap();
clear_revolut_env();
std::env::set_var("REVOLUT_API_KEY", "sk_test");
std::env::set_var("REVOLUT_WEBHOOK_SECRET", "wsk_test");

let cfg = RevolutConfig::from_env().unwrap().expect("config present");
assert_eq!(cfg.api_key, "sk_test");
assert_eq!(cfg.webhook_secret, "wsk_test");
assert_eq!(cfg.api_base, "https://sandbox-merchant.revolut.com");
assert_eq!(cfg.api_version, "2024-09-01");
assert!(cfg.return_url.is_none());

clear_revolut_env();
}

#[test]
fn revolut_config_reads_secrets_from_file_mounts() {
let _g = ENV_LOCK.lock().unwrap();
clear_revolut_env();

// Unique per-test-run paths under the OS temp dir so parallel
// crates don't collide.
let dir = std::env::temp_dir();
let key_path = dir.join("starstats-test-revolut-api-key");
let sec_path = dir.join("starstats-test-revolut-webhook-secret");
std::fs::write(&key_path, "sk_from_file\n").unwrap();
std::fs::write(&sec_path, "wsk_from_file\r\n").unwrap();

std::env::set_var("REVOLUT_API_KEY_FILE", &key_path);
std::env::set_var("REVOLUT_WEBHOOK_SECRET_FILE", &sec_path);
std::env::set_var("REVOLUT_API_BASE", "https://merchant.revolut.com");
std::env::set_var(
"REVOLUT_RETURN_URL",
"https://app.example.com/donate/return",
);

let cfg = RevolutConfig::from_env().unwrap().expect("config present");
assert_eq!(cfg.api_key, "sk_from_file", "CRLF must be trimmed");
assert_eq!(cfg.webhook_secret, "wsk_from_file");
assert_eq!(cfg.api_base, "https://merchant.revolut.com");
assert_eq!(
cfg.return_url.as_deref(),
Some("https://app.example.com/donate/return")
);

clear_revolut_env();
let _ = std::fs::remove_file(&key_path);
let _ = std::fs::remove_file(&sec_path);
}

#[test]
fn revolut_config_inline_env_overrides_file() {
let _g = ENV_LOCK.lock().unwrap();
clear_revolut_env();

let dir = std::env::temp_dir();
let key_path = dir.join("starstats-test-revolut-api-key-override");
std::fs::write(&key_path, "sk_from_file").unwrap();

std::env::set_var("REVOLUT_API_KEY", "sk_inline_wins");
std::env::set_var("REVOLUT_API_KEY_FILE", &key_path);
std::env::set_var("REVOLUT_WEBHOOK_SECRET", "wsk");

let cfg = RevolutConfig::from_env().unwrap().expect("config present");
assert_eq!(
cfg.api_key, "sk_inline_wins",
"inline env should take precedence over _FILE"
);

clear_revolut_env();
let _ = std::fs::remove_file(&key_path);
}
}
45 changes: 37 additions & 8 deletions docs/REVOLUT-INTEGRATION-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,18 +119,47 @@ Optional (sensible defaults):

### Setup checklist (when you provision credentials)

1. In the Revolut Business dashboard, go to Merchant API → Issue API
key. Copy it — it's only shown once.
2. Go to Webhooks → Add webhook. Set the URL to
`https://api.example.com/v1/webhooks/revolut` (or local equivalent).
3. Subscribe to events: `ORDER_COMPLETED`, `ORDER_FAILED`,
`ORDER_CANCELLED`. (We tolerate other events but only act on these.)
4. Copy the webhook signing secret — also only shown once.
5. Set the env vars on the deploy. The server logs
1. In the Revolut Business dashboard, navigate to
**Merchant → APIs → Merchant API → API Keys** and issue a new
key. Copy it — only shown once. This is `REVOLUT_API_KEY`.

2. Provision the webhook **via the API** (the dashboard no longer
has a Webhooks UI — webhook management is API-only as of the
`2024-09-01` Merchant API version). Run against sandbox first:

```bash
curl -X POST https://sandbox-merchant.revolut.com/api/1.0/webhooks \
-H "Authorization: Bearer $REVOLUT_API_KEY" \
-H "Revolut-Api-Version: 2024-09-01" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/v1/webhooks/revolut",
"events": ["ORDER_COMPLETED", "ORDER_FAILED", "ORDER_CANCELLED"]
}'
```

For production swap the host to `https://merchant.revolut.com`.
The response carries `id` (webhook_id, needed later for PATCH /
DELETE / signing-secret rotation) and `signing_secret`.

3. Copy `response.signing_secret` into `REVOLUT_WEBHOOK_SECRET`. The
secret is also retrievable later via
`GET /api/1.0/webhooks/{id}` if you lose it before storing —
unlike the API key, which is genuinely one-shot.

4. Set the env vars on the deploy. The server logs
`Revolut Business merchant API configured` at boot when the keys
resolve; absence logs the matching "not configured" line and the
donate routes 503.

Other Merchant API webhook events the server tolerates but does not
act on: `ORDER_AUTHORISED` (informational — for the instant-capture
donate flow it precedes `ORDER_COMPLETED` by milliseconds with no
useful action between them), plus the broader
`ORDER_PAYMENT_*` / `ORDER_INCREMENTAL_AUTHORISATION_*` families
that aren't relevant to one-shot hosted checkouts. Subscribing
to the three terminal events above is sufficient.

## Tiers

Defined as a `const` table in `revolut_routes.rs::TIERS`. Today's set:
Expand Down
Loading