Skip to content

Commit c623094

Browse files
committed
feat: add payment links and checkout success_url
Payment Links: reusable no-code URLs tied to a price — each visit creates a fresh invoice with unique address/memo, redirects to checkout. CRUD API with rate-limited public resolve endpoint, dashboard tab, slug-based routing. Includes abuse mitigations (rate limiting, unpredictable slugs, active/inactive toggle). Checkout API: add optional success_url field to POST /api/checkout, return checkout_url in response with redirect baked in. Enables single-API-call platform integrations (Luma, etc). Zero breaking changes — field is optional, checkout_url is additive.
1 parent 168c9d6 commit c623094

8 files changed

Lines changed: 577 additions & 3 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
*.db
66
*.db-shm
77
*.db-wal
8+
ROADMAP-INTERNAL.md

ROADMAP.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,19 @@ Privacy-preserving Zcash payment gateway. Non-custodial, shielded-only.
5555

5656
## Phase 3 -- Integrations & Go-to-Market
5757

58-
- [ ] **Hosted checkout page** (`pay.cipherpay.app/{invoice_id}`)
58+
- [x] **Hosted checkout page** (`pay.cipherpay.app/{invoice_id}`)
5959
- Standalone payment page merchants can redirect to
6060
- Mobile-optimized with QR code and deep-link to Zashi/YWallet
61-
- [ ] **Shopify Custom App integration**
61+
- Checkout API returns `checkout_url` with optional `success_url` redirect baked in
62+
- [x] **Shopify Custom App integration**
6263
- Merchant installs Custom App in Shopify admin
6364
- CipherPay marks orders as paid via Shopify Admin REST API
6465
- (`POST /admin/api/2024-10/orders/{id}/transactions.json`)
6566
- Avoids Shopify App Store approval process
66-
- [ ] **WooCommerce plugin** (WordPress/PHP webhook receiver)
67+
- [x] **WooCommerce plugin** (WordPress/PHP webhook receiver)
68+
- [x] **Payment Links (no-code)**
69+
- Reusable URLs tied to a price — visit creates invoice, redirects to checkout
70+
- Dashboard tab for managing links; public resolve endpoint with rate limiting
6771
- [ ] **Embeddable widget** (JS snippet for any website)
6872
- `<script src="https://pay.cipherpay.app/widget.js">`
6973
- Drop-in payment button with modal checkout

src/api/mod.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod events;
44
pub mod invoices;
55
pub mod luma;
66
pub mod merchants;
7+
pub mod payment_links;
78
pub mod prices;
89
pub mod products;
910
pub mod rates;
@@ -84,6 +85,12 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
8485
.route("/subscriptions", web::post().to(subscriptions::create))
8586
.route("/subscriptions", web::get().to(subscriptions::list))
8687
.route("/subscriptions/{id}/cancel", web::post().to(subscriptions::cancel))
88+
// Payment links (merchant auth)
89+
.route("/payment-links", web::post().to(payment_links::create))
90+
.route("/payment-links", web::get().to(payment_links::list))
91+
.route("/payment-links/{id}", web::patch().to(payment_links::update))
92+
.route("/payment-links/{id}", web::delete().to(payment_links::delete))
93+
.route("/payment-links/{slug}/checkout", web::post().to(payment_links::resolve).wrap(Governor::new(&session_rate_limit)))
8794
// Buyer checkout (public)
8895
.route("/checkout", web::post().to(checkout))
8996
// Invoice endpoints (API key auth)
@@ -419,6 +426,17 @@ async fn checkout(
419426
obj.insert("event_location".to_string(), serde_json::to_value(ctx.event_location).unwrap_or(serde_json::Value::Null));
420427
}
421428
obj.insert("is_luma".to_string(), serde_json::json!(is_luma_event));
429+
430+
let frontend_url = config.frontend_url.as_deref().unwrap_or("https://cipherpay.app");
431+
let mut checkout_url = format!("{}/pay/{}", frontend_url, resp.invoice_id);
432+
if let Some(ref url) = body.success_url {
433+
let encoded: String = url.chars().map(|c| match c {
434+
'&' | '=' | '?' | '#' | ' ' => format!("%{:02X}", c as u8),
435+
_ => c.to_string(),
436+
}).collect();
437+
checkout_url = format!("{}?return_url={}", checkout_url, encoded);
438+
}
439+
obj.insert("checkout_url".to_string(), serde_json::Value::String(checkout_url));
422440
}
423441
actix_web::HttpResponse::Created().json(payload)
424442
}
@@ -439,6 +457,7 @@ struct CheckoutRequest {
439457
refund_address: Option<String>,
440458
attendee_name: Option<String>,
441459
attendee_email: Option<String>,
460+
success_url: Option<String>,
442461
}
443462

444463
fn validate_checkout(req: &CheckoutRequest) -> Result<(), crate::validation::ValidationError> {
@@ -459,6 +478,14 @@ fn validate_checkout(req: &CheckoutRequest) -> Result<(), crate::validation::Val
459478
crate::validation::validate_zcash_address("refund_address", addr)?;
460479
}
461480
}
481+
if let Some(ref url) = req.success_url {
482+
crate::validation::validate_length("success_url", url, 2000)?;
483+
if !url.starts_with("https://") && !url.starts_with("http://") {
484+
return Err(crate::validation::ValidationError::invalid(
485+
"success_url", "must be a valid HTTP(S) URL"
486+
));
487+
}
488+
}
462489
Ok(())
463490
}
464491

src/api/payment_links.rs

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
use actix_web::{web, HttpRequest, HttpResponse};
2+
use sqlx::SqlitePool;
3+
4+
use crate::payment_links::{self, CreatePaymentLinkRequest, UpdatePaymentLinkRequest};
5+
use crate::validation;
6+
7+
pub async fn create(
8+
req: HttpRequest,
9+
pool: web::Data<SqlitePool>,
10+
body: web::Json<CreatePaymentLinkRequest>,
11+
) -> HttpResponse {
12+
let merchant = match super::auth::resolve_merchant_or_session(&req, &pool).await {
13+
Some(m) => m,
14+
None => {
15+
return HttpResponse::Unauthorized().json(serde_json::json!({
16+
"error": "Not authenticated"
17+
}));
18+
}
19+
};
20+
21+
if let Err(e) = validate_create(&body) {
22+
return HttpResponse::BadRequest().json(e.to_json());
23+
}
24+
25+
match payment_links::create_payment_link(pool.get_ref(), &merchant.id, &body).await {
26+
Ok(link) => HttpResponse::Created().json(link_response(&link)),
27+
Err(e) => {
28+
tracing::error!(error = %e, "Failed to create payment link");
29+
HttpResponse::BadRequest().json(serde_json::json!({
30+
"error": e.to_string()
31+
}))
32+
}
33+
}
34+
}
35+
36+
pub async fn list(
37+
req: HttpRequest,
38+
pool: web::Data<SqlitePool>,
39+
) -> HttpResponse {
40+
let merchant = match super::auth::resolve_merchant_or_session(&req, &pool).await {
41+
Some(m) => m,
42+
None => {
43+
return HttpResponse::Unauthorized().json(serde_json::json!({
44+
"error": "Not authenticated"
45+
}));
46+
}
47+
};
48+
49+
match payment_links::list_payment_links(pool.get_ref(), &merchant.id).await {
50+
Ok(links) => {
51+
let result: Vec<_> = links.iter().map(link_response).collect();
52+
HttpResponse::Ok().json(result)
53+
}
54+
Err(e) => {
55+
tracing::error!(error = %e, "Failed to list payment links");
56+
HttpResponse::InternalServerError().json(serde_json::json!({
57+
"error": "Internal error"
58+
}))
59+
}
60+
}
61+
}
62+
63+
pub async fn update(
64+
req: HttpRequest,
65+
pool: web::Data<SqlitePool>,
66+
path: web::Path<String>,
67+
body: web::Json<UpdatePaymentLinkRequest>,
68+
) -> HttpResponse {
69+
let merchant = match super::auth::resolve_merchant_or_session(&req, &pool).await {
70+
Some(m) => m,
71+
None => {
72+
return HttpResponse::Unauthorized().json(serde_json::json!({
73+
"error": "Not authenticated"
74+
}));
75+
}
76+
};
77+
78+
let link_id = path.into_inner();
79+
80+
if let Err(e) = validate_update(&body) {
81+
return HttpResponse::BadRequest().json(e.to_json());
82+
}
83+
84+
match payment_links::update_payment_link(pool.get_ref(), &link_id, &merchant.id, &body).await {
85+
Ok(Some(link)) => HttpResponse::Ok().json(link_response(&link)),
86+
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
87+
"error": "Payment link not found"
88+
})),
89+
Err(e) => {
90+
tracing::error!(error = %e, "Failed to update payment link");
91+
HttpResponse::BadRequest().json(serde_json::json!({
92+
"error": e.to_string()
93+
}))
94+
}
95+
}
96+
}
97+
98+
pub async fn delete(
99+
req: HttpRequest,
100+
pool: web::Data<SqlitePool>,
101+
path: web::Path<String>,
102+
) -> HttpResponse {
103+
let merchant = match super::auth::resolve_merchant_or_session(&req, &pool).await {
104+
Some(m) => m,
105+
None => {
106+
return HttpResponse::Unauthorized().json(serde_json::json!({
107+
"error": "Not authenticated"
108+
}));
109+
}
110+
};
111+
112+
let link_id = path.into_inner();
113+
114+
match payment_links::delete_payment_link(pool.get_ref(), &link_id, &merchant.id).await {
115+
Ok(true) => HttpResponse::Ok().json(serde_json::json!({ "status": "deleted" })),
116+
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
117+
"error": "Payment link not found"
118+
})),
119+
Err(e) => {
120+
tracing::error!(error = %e, "Failed to delete payment link");
121+
HttpResponse::InternalServerError().json(serde_json::json!({
122+
"error": "Internal error"
123+
}))
124+
}
125+
}
126+
}
127+
128+
/// Public endpoint: resolve a payment link by slug and create an invoice.
129+
/// Rate limited to prevent invoice flooding.
130+
pub async fn resolve(
131+
pool: web::Data<SqlitePool>,
132+
config: web::Data<crate::config::Config>,
133+
price_service: web::Data<crate::invoices::pricing::PriceService>,
134+
path: web::Path<String>,
135+
) -> HttpResponse {
136+
let slug = path.into_inner();
137+
138+
let link = match payment_links::get_by_slug(pool.get_ref(), &slug).await {
139+
Ok(Some(l)) if l.active == 1 => l,
140+
Ok(Some(_)) => {
141+
return HttpResponse::Gone().json(serde_json::json!({
142+
"error": "This payment link is no longer active"
143+
}));
144+
}
145+
Ok(None) => {
146+
return HttpResponse::NotFound().json(serde_json::json!({
147+
"error": "Payment link not found"
148+
}));
149+
}
150+
Err(e) => {
151+
tracing::error!(error = %e, "Failed to resolve payment link");
152+
return HttpResponse::InternalServerError().json(serde_json::json!({
153+
"error": "Internal error"
154+
}));
155+
}
156+
};
157+
158+
let price = match crate::prices::get_price(pool.get_ref(), &link.price_id).await {
159+
Ok(Some(p)) if p.active == 1 => p,
160+
_ => {
161+
return HttpResponse::Gone().json(serde_json::json!({
162+
"error": "Price associated with this link is no longer active"
163+
}));
164+
}
165+
};
166+
167+
let product = match crate::products::get_product(pool.get_ref(), &price.product_id).await {
168+
Ok(Some(p)) if p.active == 1 => p,
169+
_ => {
170+
return HttpResponse::Gone().json(serde_json::json!({
171+
"error": "Product associated with this link is no longer available"
172+
}));
173+
}
174+
};
175+
176+
let merchant = match crate::merchants::get_merchant_by_id(
177+
pool.get_ref(), &link.merchant_id, &config.encryption_key
178+
).await {
179+
Ok(Some(m)) => m,
180+
_ => {
181+
return HttpResponse::InternalServerError().json(serde_json::json!({
182+
"error": "Merchant not found"
183+
}));
184+
}
185+
};
186+
187+
if config.fee_enabled() {
188+
if let Ok(status) = crate::billing::get_merchant_billing_status(pool.get_ref(), &merchant.id).await {
189+
if status == "past_due" || status == "suspended" {
190+
return HttpResponse::PaymentRequired().json(serde_json::json!({
191+
"error": "Merchant account has outstanding fees"
192+
}));
193+
}
194+
}
195+
}
196+
197+
let rates = match price_service.get_rates().await {
198+
Ok(r) => r,
199+
Err(e) => {
200+
tracing::error!(error = %e, "Failed to fetch ZEC rate for payment link");
201+
return HttpResponse::ServiceUnavailable().json(serde_json::json!({
202+
"error": "Price feed unavailable"
203+
}));
204+
}
205+
};
206+
207+
let invoice_req = crate::invoices::CreateInvoiceRequest {
208+
product_id: Some(product.id.clone()),
209+
price_id: Some(price.id.clone()),
210+
product_name: Some(product.name.clone()),
211+
size: None,
212+
amount: price.unit_amount,
213+
currency: Some(price.currency.clone()),
214+
refund_address: None,
215+
};
216+
217+
let fee_config = if config.fee_enabled() {
218+
config.fee_address.as_ref().map(|addr| crate::invoices::FeeConfig {
219+
fee_address: addr.clone(),
220+
fee_rate: config.fee_rate,
221+
})
222+
} else {
223+
None
224+
};
225+
226+
match crate::invoices::create_invoice(
227+
pool.get_ref(),
228+
&merchant.id,
229+
&merchant.ufvk,
230+
&invoice_req,
231+
&rates,
232+
config.invoice_expiry_minutes,
233+
fee_config.as_ref(),
234+
)
235+
.await
236+
{
237+
Ok(resp) => {
238+
let _ = payment_links::increment_created(pool.get_ref(), &link.id).await;
239+
240+
let frontend_url = config.frontend_url.as_deref().unwrap_or("https://cipherpay.app");
241+
let mut checkout_url = format!("{}/pay/{}", frontend_url, resp.invoice_id);
242+
if let Some(ref success) = link.success_url {
243+
let encoded: String = success.chars().map(|c| match c {
244+
'&' | '=' | '?' | '#' | ' ' => format!("%{:02X}", c as u8),
245+
_ => c.to_string(),
246+
}).collect();
247+
checkout_url = format!("{}?return_url={}", checkout_url, encoded);
248+
}
249+
250+
HttpResponse::Created().json(serde_json::json!({
251+
"invoice_id": resp.invoice_id,
252+
"checkout_url": checkout_url,
253+
"payment_address": resp.payment_address,
254+
"amount": resp.amount,
255+
"currency": resp.currency,
256+
"price_zec": resp.price_zec,
257+
"zcash_uri": resp.zcash_uri,
258+
"expires_at": resp.expires_at,
259+
"product_name": product.name,
260+
"link_name": link.name,
261+
}))
262+
}
263+
Err(e) => {
264+
tracing::error!(error = %e, slug = %slug, "Payment link invoice creation failed");
265+
HttpResponse::InternalServerError().json(serde_json::json!({
266+
"error": "Failed to create invoice"
267+
}))
268+
}
269+
}
270+
}
271+
272+
fn link_response(link: &payment_links::PaymentLink) -> serde_json::Value {
273+
serde_json::json!({
274+
"id": link.id,
275+
"merchant_id": link.merchant_id,
276+
"price_id": link.price_id,
277+
"slug": link.slug,
278+
"name": link.name,
279+
"success_url": link.success_url,
280+
"metadata": link.metadata_json(),
281+
"active": link.active == 1,
282+
"total_created": link.total_created,
283+
"created_at": link.created_at,
284+
})
285+
}
286+
287+
fn validate_create(req: &CreatePaymentLinkRequest) -> Result<(), validation::ValidationError> {
288+
validation::validate_length("price_id", &req.price_id, 100)?;
289+
if let Some(ref name) = req.name {
290+
validation::validate_length("name", name, 200)?;
291+
}
292+
if let Some(ref url) = req.success_url {
293+
validation::validate_length("success_url", url, 2000)?;
294+
}
295+
Ok(())
296+
}
297+
298+
fn validate_update(req: &UpdatePaymentLinkRequest) -> Result<(), validation::ValidationError> {
299+
if let Some(ref name) = req.name {
300+
validation::validate_length("name", name, 200)?;
301+
}
302+
if let Some(ref url) = req.success_url {
303+
validation::validate_length("success_url", url, 2000)?;
304+
}
305+
Ok(())
306+
}

0 commit comments

Comments
 (0)