From 614d88c9743a628048e627f62086d1f6fc8ebfea Mon Sep 17 00:00:00 2001 From: Emmyt24 Date: Mon, 3 Aug 2026 12:36:50 +0100 Subject: [PATCH 1/2] feat: hosted checkout URL, redirect_url, and OpenAPI docs for payment links A developer had no way to actually integrate payment links into their own checkout: create-link responses only returned a bare slug (the /pay/{slug} URL convention existed only as client-side JS in the dashboard), there was no way to redirect a payer back to the merchant's site after paying, and docs/openapi.yaml had zero entries for any payment-link route. - migration 0017: nullable redirect_url column on payment_links. - PaymentLinkView / PublicPaymentLinkView gain url (the full hosted checkout URL, built server-side from a new PUBLIC_APP_URL config, defaulted to localhost:3000 like HORIZON_URL) and redirect_url (bare passthrough, same treatment as image_url -- it's merchant-set via their own API key and only ever used for a browser redirect, never fetched server-side, so no SSRF surface). - docs/openapi.yaml: added all 8 payment-link routes (owner-authenticated create/list/get/activate/payments, and the public /v1/pay/:slug/* checkout flow) with matching response schemas, mirroring the actual Rust response shapes field-for-field. No SDK -- this is a server-to-server integration (create a link, get a URL, redirect, get webhooked), not client-side signing. --- .env.example | 4 + bin/server/src/main.rs | 10 + crates/api/src/routes/payment_links.rs | 45 +- crates/api/src/state.rs | 14 + .../0017_payment_link_redirect_url.sql | 5 + crates/store/src/lib.rs | 5 +- crates/store/src/models.rs | 2 + crates/store/tests/store_tests.rs | 7 +- docs/openapi.yaml | 589 ++++++++++++++++++ 9 files changed, 666 insertions(+), 15 deletions(-) create mode 100644 crates/store/migrations/0017_payment_link_redirect_url.sql diff --git a/.env.example b/.env.example index a8142c3..13d4c6d 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,10 @@ JWT_SECRET= BIND_ADDR=0.0.0.0:8080 RUST_LOG=info,octo=debug +# Base URL of the hosted checkout frontend (Next.js), used to build the `url` field on payment +# links (e.g. https://app.octo.dev/pay/). No trailing slash. +PUBLIC_APP_URL=http://localhost:3000 + # --- Ingest worker --- # How often the deposit ingest supervisor polls Horizon for all wallets, and the page size. INGEST_INTERVAL_SECS=5 diff --git a/bin/server/src/main.rs b/bin/server/src/main.rs index 67bfc25..366bebf 100644 --- a/bin/server/src/main.rs +++ b/bin/server/src/main.rs @@ -48,6 +48,7 @@ async fn main() -> Result<()> { cfg.network, cfg.horizon_url.clone(), cfg.friendbot_url.clone(), + cfg.public_app_url.clone(), resilience.retry_policy(), resilience.circuit_breaker(), ) @@ -115,6 +116,9 @@ struct Config { network: StellarNetwork, horizon_url: String, friendbot_url: Option, + /// Base URL of the hosted checkout frontend (e.g. `https://app.octo.dev`), used to build the + /// `url` field on payment-link responses. Defaults to the local frontend dev server. + public_app_url: String, master_key: [u8; 32], /// Optional next master key for zero-downtime rotation. Present only during the rotation /// window while `octo-migrate-keys` is backfilling. When set, the server uses this key as @@ -150,6 +154,11 @@ impl Config { .unwrap_or_else(|_| "https://horizon-testnet.stellar.org".to_string()); let friendbot_url = std::env::var("FRIENDBOT_URL").ok(); + let public_app_url = std::env::var("PUBLIC_APP_URL") + .unwrap_or_else(|_| "http://localhost:3000".to_string()) + .trim_end_matches('/') + .to_string(); + let master_key_b64 = std::env::var("MASTER_KEY").context("MASTER_KEY is required")?; let master_key = AppState::decode_master_key(&master_key_b64) .map_err(|_| anyhow::anyhow!("MASTER_KEY must be base64-encoded 32 bytes"))?; @@ -189,6 +198,7 @@ impl Config { network, horizon_url, friendbot_url, + public_app_url, master_key, master_key_next, jwt_secret, diff --git a/crates/api/src/routes/payment_links.rs b/crates/api/src/routes/payment_links.rs index f634a1f..7e9681d 100644 --- a/crates/api/src/routes/payment_links.rs +++ b/crates/api/src/routes/payment_links.rs @@ -18,6 +18,11 @@ const USDC_ASSET_CODE: &str = "USDC"; /// any asset landing at the right address. const USDC_TESTNET_ISSUER: &str = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; +/// Full hosted checkout URL for a payment link's slug. +fn checkout_url(state: &AppState, slug: &str) -> String { + format!("{}/pay/{}", state.public_app_url(), slug) +} + #[derive(Debug, Serialize)] pub struct PaymentLinkView { pub id: Uuid, @@ -25,10 +30,12 @@ pub struct PaymentLinkView { pub name: String, pub description: Option, pub image_url: Option, + pub redirect_url: Option, pub amount_usdc_stroops: Option, pub active: bool, pub collected_usdc_stroops: i64, pub created_at: chrono::DateTime, + pub url: String, } #[derive(Debug, Default, Deserialize)] @@ -36,6 +43,7 @@ pub struct CreatePaymentLinkRequest { pub name: Option, pub description: Option, pub image_url: Option, + pub redirect_url: Option, pub amount_usdc_stroops: Option, } @@ -78,20 +86,24 @@ pub async fn create_payment_link( name: &name, description: req.description.as_deref(), image_url: req.image_url.as_deref(), + redirect_url: req.redirect_url.as_deref(), amount_usdc_stroops: req.amount_usdc_stroops, }) .await?; + let url = checkout_url(&state, &link.slug); let (code, json) = Envelope::created(PaymentLinkView { id: link.id, slug: link.slug, name: link.name, description: link.description, image_url: link.image_url, + redirect_url: link.redirect_url, amount_usdc_stroops: link.amount_usdc_stroops, active: link.active, collected_usdc_stroops: 0, created_at: link.created_at, + url, }); Ok((code, json)) } @@ -131,16 +143,21 @@ pub async fn list_payment_links( let views = items .into_iter() - .map(|l| PaymentLinkView { - id: l.id, - slug: l.slug, - name: l.name, - description: l.description, - image_url: l.image_url, - amount_usdc_stroops: l.amount_usdc_stroops, - active: l.active, - collected_usdc_stroops: totals.get(&l.id).copied().unwrap_or(0), - created_at: l.created_at, + .map(|l| { + let url = checkout_url(&state, &l.slug); + PaymentLinkView { + id: l.id, + slug: l.slug, + name: l.name, + description: l.description, + image_url: l.image_url, + redirect_url: l.redirect_url, + amount_usdc_stroops: l.amount_usdc_stroops, + active: l.active, + collected_usdc_stroops: totals.get(&l.id).copied().unwrap_or(0), + created_at: l.created_at, + url, + } }) .collect(); @@ -165,16 +182,19 @@ pub async fn get_payment_link( authorize_wallet(&headers, &state, wallet_id).await?; let link = state.store().get_payment_link(wallet_id, link_id).await?; let collected = state.store().sum_payment_link_collected(link.id).await?; + let url = checkout_url(&state, &link.slug); Ok(Envelope::ok(PaymentLinkView { id: link.id, slug: link.slug, name: link.name, description: link.description, image_url: link.image_url, + redirect_url: link.redirect_url, amount_usdc_stroops: link.amount_usdc_stroops, active: link.active, collected_usdc_stroops: collected, created_at: link.created_at, + url, })) } @@ -268,16 +288,19 @@ pub async fn set_payment_link_active( .set_payment_link_active(wallet_id, link_id, active) .await?; let collected = state.store().sum_payment_link_collected(link.id).await?; + let url = checkout_url(&state, &link.slug); Ok(Envelope::ok(PaymentLinkView { id: link.id, slug: link.slug, name: link.name, description: link.description, image_url: link.image_url, + redirect_url: link.redirect_url, amount_usdc_stroops: link.amount_usdc_stroops, active: link.active, collected_usdc_stroops: collected, created_at: link.created_at, + url, })) } @@ -286,6 +309,7 @@ pub struct PublicPaymentLinkView { pub name: String, pub description: Option, pub image_url: Option, + pub redirect_url: Option, pub amount_usdc_stroops: Option, pub deposit_address: String, pub asset_code: String, @@ -337,6 +361,7 @@ pub async fn get_public_payment_link( name: link.name, description: link.description, image_url: link.image_url, + redirect_url: link.redirect_url, amount_usdc_stroops: link.amount_usdc_stroops, deposit_address: address.muxed_address, asset_code: USDC_ASSET_CODE.into(), diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index b1b6ff2..e723d51 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -30,6 +30,9 @@ struct Inner { horizon: Horizon, horizon_url: String, friendbot_url: Option, + /// Base URL of the hosted checkout frontend, used to build the `url` field on payment-link + /// responses (e.g. `https://app.octo.dev/pay/`). No trailing slash. + public_app_url: String, /// HMAC secret for signing dashboard auth JWTs. jwt_secret: Vec, /// Fires signed webhooks (e.g. `transaction.sponsored`) to registered endpoints. @@ -86,6 +89,7 @@ impl AppState { network, horizon_url, friendbot_url, + "http://localhost:3000".to_string(), secret, octo_resilience::RetryPolicy::default(), octo_resilience::CircuitBreaker::new(5, std::time::Duration::from_secs(30)), @@ -93,12 +97,14 @@ impl AppState { } /// Build state with explicit resilience configuration (used by `bin/server`). + #[allow(clippy::too_many_arguments)] pub fn new_with_resilience( store: Store, master_key: [u8; MASTER_KEY_LEN], network: StellarNetwork, horizon_url: String, friendbot_url: Option, + public_app_url: String, retry: octo_resilience::RetryPolicy, circuit: octo_resilience::CircuitBreaker, ) -> Self { @@ -111,6 +117,7 @@ impl AppState { network, horizon_url, friendbot_url, + public_app_url, secret, retry, circuit, @@ -142,6 +149,7 @@ impl AppState { network: StellarNetwork, horizon_url: String, friendbot_url: Option, + public_app_url: String, jwt_secret: Vec, retry: RetryPolicy, circuit: CircuitBreaker, @@ -159,6 +167,7 @@ impl AppState { horizon, horizon_url, friendbot_url, + public_app_url, jwt_secret, webhooks, }), @@ -234,4 +243,9 @@ impl AppState { pub fn friendbot_url(&self) -> Option<&str> { self.inner.friendbot_url.as_deref() } + + /// Base URL of the hosted checkout frontend (no trailing slash). + pub fn public_app_url(&self) -> &str { + &self.inner.public_app_url + } } diff --git a/crates/store/migrations/0017_payment_link_redirect_url.sql b/crates/store/migrations/0017_payment_link_redirect_url.sql new file mode 100644 index 0000000..f9c72a0 --- /dev/null +++ b/crates/store/migrations/0017_payment_link_redirect_url.sql @@ -0,0 +1,5 @@ +-- Optional post-checkout redirect: developer-supplied via their own API key at link-creation +-- time, used only to send the payer's own browser back to the merchant's site after +-- confirmation. Not attacker-controlled and never fetched server-side, so no SSRF/allowlist +-- validation is needed — same bare-passthrough treatment as image_url. +ALTER TABLE payment_links ADD COLUMN redirect_url TEXT; diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index be244b1..ce251a9 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -1137,8 +1137,8 @@ impl Store { let row = sqlx::query_as::<_, PaymentLink>( r#" INSERT INTO payment_links - (wallet_id, address_id, slug, name, description, image_url, amount_usdc_stroops) - VALUES ($1, $2, $3, $4, $5, $6, $7) + (wallet_id, address_id, slug, name, description, image_url, redirect_url, amount_usdc_stroops) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING * "#, ) @@ -1148,6 +1148,7 @@ impl Store { .bind(link.name) .bind(link.description) .bind(link.image_url) + .bind(link.redirect_url) .bind(link.amount_usdc_stroops) .fetch_one(&self.pool) .await diff --git a/crates/store/src/models.rs b/crates/store/src/models.rs index ce35418..d402964 100644 --- a/crates/store/src/models.rs +++ b/crates/store/src/models.rs @@ -259,6 +259,7 @@ pub struct PaymentLink { pub name: String, pub description: Option, pub image_url: Option, + pub redirect_url: Option, pub amount_usdc_stroops: Option, pub active: bool, pub created_at: DateTime, @@ -274,6 +275,7 @@ pub struct NewPaymentLink<'a> { pub name: &'a str, pub description: Option<&'a str>, pub image_url: Option<&'a str>, + pub redirect_url: Option<&'a str>, pub amount_usdc_stroops: Option, } diff --git a/crates/store/tests/store_tests.rs b/crates/store/tests/store_tests.rs index 757aa36..69a6f75 100644 --- a/crates/store/tests/store_tests.rs +++ b/crates/store/tests/store_tests.rs @@ -333,6 +333,7 @@ async fn payment_link_lifecycle_intent_confirm_and_sum() { name: "Support octo", description: Some("donations"), image_url: None, + redirect_url: None, amount_usdc_stroops: None, }) .await @@ -687,7 +688,7 @@ async fn migrate_applies_exactly_the_expected_version_set() { .expect("query _sqlx_migrations"); versions.sort_unstable(); - // One version per file under crates/store/migrations/, 0001_init.sql .. 0016. + // One version per file under crates/store/migrations/, 0001_init.sql .. 0017. // // NOTE: this version number is a repeat offender — five migrations have now landed with a // colliding 0008 at one point or another (scheme_version, token_denylist, @@ -697,8 +698,8 @@ async fn migrate_applies_exactly_the_expected_version_set() { // every version explicitly rather than just checking a count. assert_eq!( versions, - vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - "expected exactly the sixteen known migrations to be recorded as applied" + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17], + "expected exactly the seventeen known migrations to be recorded as applied" ); } diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 73003c8..f868daf 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -162,6 +162,293 @@ paths: application/json: schema: $ref: '#/components/schemas/ListTransactionsResponse' + /v1/wallets/{id}/payment-links: + post: + summary: Create a payment link + operationId: createPaymentLink + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + description: + type: string + nullable: true + image_url: + type: string + nullable: true + redirect_url: + type: string + nullable: true + description: > + Where to send the payer's browser after their payment is confirmed. Octo + appends `?status=success&payment_id=&slug=` when redirecting. + amount_usdc_stroops: + type: integer + nullable: true + description: Fixed amount in USDC stroops (7dp). Omit/null for a flexible-amount link. + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/PaymentLinkResponse' + get: + summary: List payment links + operationId: listPaymentLinks + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: limit + in: query + schema: + type: integer + - name: before + in: query + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ListPaymentLinksResponse' + /v1/wallets/{id}/payment-links/{link_id}: + get: + summary: Get a payment link + operationId: getPaymentLink + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: link_id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PaymentLinkResponse' + put: + summary: Activate or deactivate a payment link + operationId: setPaymentLinkActive + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: link_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: object + required: + - active + properties: + active: + type: boolean + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PaymentLinkResponse' + /v1/wallets/{id}/payment-links/{link_id}/payments: + get: + summary: List payments recorded against a payment link + description: Owner-authenticated only — includes payer name/email. + operationId: listPaymentLinkPayments + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: link_id + in: path + required: true + schema: + type: string + format: uuid + - name: limit + in: query + schema: + type: integer + - name: before + in: query + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ListPaymentLinkPaymentsResponse' + /v1/pay/{slug}: + get: + summary: Get a public payment link + description: Public, no auth. Used by the hosted checkout page. + operationId: getPublicPaymentLink + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PublicPaymentLinkResponse' + /v1/pay/{slug}/intent: + post: + summary: Create a payment intent against a public payment link + description: Public, no auth, rate-limited by IP. + operationId: createPaymentIntent + parameters: + - name: slug + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + payer_name: + type: string + nullable: true + payer_email: + type: string + nullable: true + amount_usdc_stroops: + type: integer + nullable: true + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/PaymentIntentResponse' + /v1/pay/{slug}/payments/{payment_id}: + get: + summary: Poll a payment's status + description: Public, no auth. + operationId: getPaymentStatus + parameters: + - name: slug + in: path + required: true + schema: + type: string + - name: payment_id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PaymentStatusResponse' + /v1/pay/{slug}/signing-info: + get: + summary: Get Stellar signing info for the payer's own account + description: Public, no auth. + operationId: publicSigningInfo + parameters: + - name: slug + in: path + required: true + schema: + type: string + - name: account + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PublicSigningInfoResponse' + /v1/pay/{slug}/submit-signed: + post: + summary: Relay a payer-signed transaction to Horizon + description: Public, no auth. + operationId: submitPayment + parameters: + - name: slug + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + transaction_xdr: + type: string + payment_id: + type: string + format: uuid + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/SubmitPaymentResponse' /v1/webhooks: post: summary: Register a webhook @@ -418,3 +705,305 @@ components: # `data` is null. (The spec previously documented a non-existent `error` field.) data: nullable: true + PaymentLinkResponse: + type: object + required: + - statusCode + - message + - data + properties: + statusCode: + type: integer + message: + type: string + data: + type: object + required: + - id + - slug + - name + - active + - collected_usdc_stroops + - created_at + - url + properties: + id: + type: string + format: uuid + slug: + type: string + name: + type: string + description: + type: string + nullable: true + image_url: + type: string + nullable: true + redirect_url: + type: string + nullable: true + amount_usdc_stroops: + type: integer + nullable: true + description: Null means flexible — the payer chooses the amount. + active: + type: boolean + collected_usdc_stroops: + type: integer + created_at: + type: string + format: date-time + url: + type: string + description: Full hosted checkout URL, e.g. `https://app.octo.dev/pay/ab12cd34ef`. + ListPaymentLinksResponse: + type: object + required: + - statusCode + - message + - data + properties: + statusCode: + type: integer + message: + type: string + data: + type: object + required: + - data + properties: + data: + type: array + items: + type: object + required: + - id + - slug + - name + - active + - collected_usdc_stroops + - created_at + - url + properties: + id: + type: string + format: uuid + slug: + type: string + name: + type: string + description: + type: string + nullable: true + image_url: + type: string + nullable: true + redirect_url: + type: string + nullable: true + amount_usdc_stroops: + type: integer + nullable: true + active: + type: boolean + collected_usdc_stroops: + type: integer + created_at: + type: string + format: date-time + url: + type: string + next_cursor: + type: string + format: uuid + nullable: true + ListPaymentLinkPaymentsResponse: + type: object + required: + - statusCode + - message + - data + properties: + statusCode: + type: integer + message: + type: string + data: + type: object + required: + - data + properties: + data: + type: array + items: + type: object + required: + - id + - amount_usdc_stroops + - status + - created_at + properties: + id: + type: string + format: uuid + payer_name: + type: string + nullable: true + payer_email: + type: string + nullable: true + amount_usdc_stroops: + type: integer + status: + type: string + enum: [pending, confirmed] + transaction_id: + type: string + format: uuid + nullable: true + created_at: + type: string + format: date-time + next_cursor: + type: string + format: uuid + nullable: true + PublicPaymentLinkResponse: + type: object + required: + - statusCode + - message + - data + properties: + statusCode: + type: integer + message: + type: string + data: + type: object + required: + - name + - deposit_address + - asset_code + properties: + name: + type: string + description: + type: string + nullable: true + image_url: + type: string + nullable: true + redirect_url: + type: string + nullable: true + amount_usdc_stroops: + type: integer + nullable: true + deposit_address: + type: string + asset_code: + type: string + PaymentIntentResponse: + type: object + required: + - statusCode + - message + - data + properties: + statusCode: + type: integer + message: + type: string + data: + type: object + required: + - payment_id + - deposit_address + - amount_usdc_stroops + properties: + payment_id: + type: string + format: uuid + deposit_address: + type: string + amount_usdc_stroops: + type: integer + PaymentStatusResponse: + type: object + required: + - statusCode + - message + - data + properties: + statusCode: + type: integer + message: + type: string + data: + type: object + required: + - status + properties: + status: + type: string + enum: [pending, confirmed] + transaction_id: + type: string + format: uuid + nullable: true + PublicSigningInfoResponse: + type: object + required: + - statusCode + - message + - data + properties: + statusCode: + type: integer + message: + type: string + data: + type: object + required: + - account + - sequence + - network_passphrase + - base_fee_stroops + properties: + account: + type: string + sequence: + type: string + description: Serialized as a string — a JS Number would lose precision. + network_passphrase: + type: string + base_fee_stroops: + type: integer + SubmitPaymentResponse: + type: object + required: + - statusCode + - message + - data + properties: + statusCode: + type: integer + message: + type: string + data: + type: object + required: + - status + properties: + status: + type: string + enum: [confirmed, failed] + stellar_tx_hash: + type: string + nullable: true + detail: + type: string + nullable: true + description: Human-readable reason when status is "failed". From 6adb2108d4b72a4a4ef41a3e7c65f72fcfd1e13d Mon Sep 17 00:00:00 2001 From: Emmyt24 Date: Mon, 3 Aug 2026 15:54:48 +0100 Subject: [PATCH 2/2] test: cover the checkout url and redirect_url fields end-to-end Confirms url is a real hosted checkout link (ends in /pay/) and redirect_url round-trips through create, owner-authenticated get, and the public /v1/pay/:slug route. --- crates/api/tests/api_tests.rs | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/api/tests/api_tests.rs b/crates/api/tests/api_tests.rs index d786836..6397d8c 100644 --- a/crates/api/tests/api_tests.rs +++ b/crates/api/tests/api_tests.rs @@ -2053,6 +2053,62 @@ async fn payment_link_management_requires_wallet_ownership() { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } +#[tokio::test] +async fn payment_link_response_includes_checkout_url_and_redirect_url() { + let Some(state) = test_state().await else { + eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); + return; + }; + let app = build_router(state); + let token = auth_token(&app).await; + let wallet_id = create_wallet_for(&app, &token).await; + + let uri = format!("/v1/wallets/{wallet_id}/payment-links"); + let resp = app + .clone() + .oneshot(post_json_auth( + &uri, + r#"{"name":"Support","redirect_url":"https://merchant.example/thank-you"}"#, + &token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + let created = body_json(resp).await; + let slug = created["data"]["slug"].as_str().unwrap().to_string(); + let url = created["data"]["url"].as_str().unwrap(); + assert!( + url.ends_with(&format!("/pay/{slug}")), + "url must be a real hosted checkout link ending in /pay/, got {url}" + ); + assert_eq!( + created["data"]["redirect_url"], + "https://merchant.example/thank-you" + ); + + // GET and the public route must echo the same fields. + let link_id = created["data"]["id"].as_str().unwrap(); + let get_uri = format!("/v1/wallets/{wallet_id}/payment-links/{link_id}"); + let resp = app + .clone() + .oneshot(get_auth(&get_uri, &token)) + .await + .unwrap(); + let fetched = body_json(resp).await; + assert_eq!(fetched["data"]["url"], url); + assert_eq!( + fetched["data"]["redirect_url"], + "https://merchant.example/thank-you" + ); + + let resp = app.oneshot(get(&format!("/v1/pay/{slug}"))).await.unwrap(); + let public = body_json(resp).await; + assert_eq!( + public["data"]["redirect_url"], + "https://merchant.example/thank-you" + ); +} + #[tokio::test] async fn payment_link_intent_rejects_flexible_amount_without_one() { let Some(state) = test_state().await else {