Skip to content

Commit df9ab76

Browse files
committed
feat: billing email notifications + per-merchant fee rates
Pre-referral billing infrastructure: - Per-merchant fee_rate column (nullable, falls back to global config) - fee_rate_applied recorded on each fee_ledger entry for audit trail - fee_discount_until column for time-limited referral discounts - email_events table for idempotent billing email delivery - 7 billing email templates (settlement, reminder, past due, suspended, payment confirmed, discount expiry warning, discount expired) - Emails hooked into process_billing_cycles and check_settlement_payments - Automated discount expiry + 7-day warning in billing loop - Admin test-email endpoint for verifying email delivery post-deploy
1 parent a54c19c commit df9ab76

7 files changed

Lines changed: 745 additions & 11 deletions

File tree

ROADMAP.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ Privacy-preserving Zcash payment gateway. Non-custodial, shielded-only.
174174
- Cron job with cryptographic erasure (not just NULL, but overwrite)
175175
- Zero-knowledge order fulfillment: merchant gets shipping label, not raw address
176176
- [ ] **PostgreSQL migration** for production (multi-tenant, proper indexing)
177+
- [ ] Separate `settlement_invoices` table from `invoices` — settlement invoices (bills from CipherPay to merchants) currently share the `invoices` table for scanner pipeline reuse; split into dedicated table with shared detection trait during Postgres migration
177178
- [ ] **Read/write role separation after Postgres** — reserve migration path for scanner-heavy reads, webhook writes, and dashboard queries so one hot connection pool does not do everything
178179
- [ ] **Multi-node CipherScan infrastructure**
179180
- Load balancer in front of multiple Zebra nodes
@@ -258,6 +259,7 @@ Privacy-preserving Zcash payment gateway. Non-custodial, shielded-only.
258259
- Enterprise: dedicated infrastructure, SLA, custom integrations
259260
- [ ] API key rate limiting per tier
260261
- [ ] Merchant dashboard (invoice history, analytics, webhook logs)
262+
- [ ] **Billing export** — CSV/PDF export of billing cycles and settlement invoices for accounting
261263
- [ ] Multi-merchant management (agencies managing multiple stores)
262264
- [ ] **Evaluate `mpp-rs` crate adoption** — replace custom MPP challenge/credential handling in `@cipherpay/x402` with the first-party [mpp-rs](https://github.com/nicholasgasior/mpp) Rust crate
263265
- Reduces protocol maintenance burden as MPP spec evolves

src/api/admin.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use actix_web::web;
22
use hmac::{Hmac, Mac};
3+
use serde::Deserialize;
34
use sha2::Sha256;
45
use sqlx::SqlitePool;
56
use subtle::ConstantTimeEq;
@@ -517,3 +518,144 @@ pub async fn system(
517518
"fee_rate": config.fee_rate,
518519
}))
519520
}
521+
522+
#[derive(Deserialize)]
523+
pub struct TestEmailRequest {
524+
pub to: String,
525+
pub template: Option<String>,
526+
}
527+
528+
/// POST /api/admin/test-email -- send a test billing email to verify email delivery
529+
pub async fn test_email(
530+
req: actix_web::HttpRequest,
531+
pool: web::Data<SqlitePool>,
532+
config: web::Data<crate::config::Config>,
533+
body: web::Json<TestEmailRequest>,
534+
) -> actix_web::HttpResponse {
535+
if !authenticate_admin(&req) {
536+
return unauthorized();
537+
}
538+
539+
if !config.smtp_configured() {
540+
return actix_web::HttpResponse::BadRequest().json(serde_json::json!({
541+
"error": "Email not configured (SMTP_FROM and SMTP_PASS required)"
542+
}));
543+
}
544+
545+
let template = body.template.as_deref().unwrap_or("settlement_invoice");
546+
let test_cycle_id = "test-email-verification";
547+
548+
// Delete any previous test email event so the test can be repeated
549+
sqlx::query("DELETE FROM email_events WHERE merchant_id = 'test' AND entity_id = ?")
550+
.bind(test_cycle_id)
551+
.execute(pool.get_ref())
552+
.await
553+
.ok();
554+
555+
let result = match template {
556+
"settlement_invoice" => {
557+
crate::email::send_settlement_invoice_email(
558+
pool.get_ref(),
559+
&config,
560+
&body.to,
561+
"test",
562+
test_cycle_id,
563+
0.12345678,
564+
"2026-04-20T00:00:00Z",
565+
7,
566+
)
567+
.await
568+
}
569+
"grace_reminder" => {
570+
crate::email::send_billing_reminder_email(
571+
pool.get_ref(),
572+
&config,
573+
&body.to,
574+
"test",
575+
test_cycle_id,
576+
0.12345678,
577+
"2026-04-20T00:00:00Z",
578+
3,
579+
)
580+
.await
581+
}
582+
"past_due" => {
583+
crate::email::send_past_due_email(
584+
pool.get_ref(),
585+
&config,
586+
&body.to,
587+
"test",
588+
test_cycle_id,
589+
0.12345678,
590+
)
591+
.await
592+
}
593+
"suspended" => {
594+
crate::email::send_suspended_email(
595+
pool.get_ref(),
596+
&config,
597+
&body.to,
598+
"test",
599+
test_cycle_id,
600+
0.12345678,
601+
)
602+
.await
603+
}
604+
"payment_confirmed" => {
605+
crate::email::send_payment_confirmed_email(
606+
pool.get_ref(),
607+
&config,
608+
&body.to,
609+
"test",
610+
test_cycle_id,
611+
)
612+
.await
613+
}
614+
"discount_expiry_warning" => {
615+
crate::email::send_discount_expiry_warning_email(
616+
pool.get_ref(),
617+
&config,
618+
&body.to,
619+
"test",
620+
0.01,
621+
"2026-04-20",
622+
)
623+
.await
624+
}
625+
"discount_expired" => {
626+
crate::email::send_discount_expired_email(
627+
pool.get_ref(),
628+
&config,
629+
&body.to,
630+
"test",
631+
0.01,
632+
)
633+
.await
634+
}
635+
_ => {
636+
return actix_web::HttpResponse::BadRequest().json(serde_json::json!({
637+
"error": "Unknown template",
638+
"valid_templates": [
639+
"settlement_invoice", "grace_reminder", "past_due",
640+
"suspended", "payment_confirmed",
641+
"discount_expiry_warning", "discount_expired"
642+
]
643+
}));
644+
}
645+
};
646+
647+
match result {
648+
Ok(true) => actix_web::HttpResponse::Ok().json(serde_json::json!({
649+
"sent": true,
650+
"template": template,
651+
"to": body.to,
652+
})),
653+
Ok(false) => actix_web::HttpResponse::Ok().json(serde_json::json!({
654+
"sent": false,
655+
"reason": "Skipped (idempotency or no email configured)"
656+
})),
657+
Err(e) => actix_web::HttpResponse::InternalServerError().json(serde_json::json!({
658+
"error": format!("Email send failed: {}", e)
659+
})),
660+
}
661+
}

src/api/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
194194
.route("/admin/merchants", web::get().to(admin::merchants))
195195
.route("/admin/billing", web::get().to(admin::billing))
196196
.route("/admin/webhooks", web::get().to(admin::webhooks))
197-
.route("/admin/system", web::get().to(admin::system)),
197+
.route("/admin/system", web::get().to(admin::system))
198+
.route("/admin/test-email", web::post().to(admin::test_email)),
198199
);
199200

200201
cfg.route("/.well-known/payment", web::get().to(well_known_payment));

0 commit comments

Comments
 (0)