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
3 changes: 3 additions & 0 deletions ADMIN_API_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7988,6 +7988,7 @@ All fields except `webhook_secret` are required when creating:
| `stripe` | stripe | `secret_key`, `publishable_key`, `webhook_secret` |
| `paypal` | paypal | `client_id`, `client_secret`, `mode` |
| `onchain` | onchain | `url`, `cert_path`, `macaroon_path` (`address_type`, `account`, `min_confirmations` optional) |
| `fixedfloat` | fixedfloat | `api_key`, `api_secret` (`url`, `refcode`, `afftax` optional) |

### PartialProviderConfig (Tagged Union) - For Updates

Expand All @@ -8001,6 +8002,7 @@ Only the `type` field is required; all other fields are optional and will keep t
| `stripe` | `secret_key`, `publishable_key`, `webhook_secret` |
| `paypal` | `client_id`, `client_secret`, `mode` |
| `onchain` | `url`, `cert_path`, `macaroon_path`, `address_type`, `account` (null clears), `min_confirmations` |
| `fixedfloat` | `api_key`, `api_secret`, `url` (null clears), `refcode` (null clears), `afftax` (null clears) |

### Notes

Expand All @@ -8011,6 +8013,7 @@ Only the `type` field is required; all other fields are optional and will keep t
- `stripe` - Stripe Payments API
- `paypal` - PayPal REST API
- `onchain` - On-chain Bitcoin payments (LND wallet backend). `address_type` is one of `witness_pubkey_hash`, `nested_pubkey_hash`, `taproot_pubkey`.
- `fixedfloat` - Cryptocurrency payments through the FixedFloat exchange API (payment method `fixedfloat`). The customer picks a coin per payment (`coin` query parameter). The coins a company accepts are the config's `supported_currencies`, which for `fixedfloat` must be coins from `XMR`, `LTC`, `ETH` and `USDTTRC`; an empty list accepts all four. Coins are rejected on every other payment method, and a coin cannot be a company or plan currency. Each payment creates a FixedFloat order into that coin whose BTC destination is a fresh address from the company's `onchain` config, so an enabled `onchain` payment method is required as well. The BTC that arrives is settled by the on-chain watcher at the rate current on arrival. The response hides `api_key` and `api_secret` and returns `has_api_key` and `has_api_secret`. `afftax` is only sent together with `refcode`.

**Multiple Configurations:**
A company can have multiple payment method configurations for the same payment method type. This allows keeping disabled
Expand Down
4 changes: 4 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

### Added

- **Cryptocurrency payments**: `fixedfloat` payment method that takes a `coin` (`XMR`, `LTC`, `ETH` or `USDTTRC`) on `method=fixedfloat&coin=...`. `PaymentData` gains `{ fixedfloat: { coin, address, amount, outpoint? } }`, the deposit address and amount of a swap into our Bitcoin wallet. Time is priced from the Bitcoin that arrives. `ApiCurrency` gains `XMR`, `LTC`, `ETH` and `USDTTRC`, which appear only in a `fixedfloat` method's `currencies`. Admins configure it with a `fixedfloat` provider config (`api_key`, `api_secret`, optional `url`, `refcode`, `afftax`) on `/api/admin/v1/payment_methods`, alongside the company's `onchain` config, and list the accepted coins in `supported_currencies`.

### Security

- **Referral self-dealing**: a VM ordered with the buyer's own referral code no longer earns commission, including VMs already ordered that way, so commission already paid on them nets against future earnings. Commission is reduced by refunds against the referred VM's first payment, and automated payouts wait until that payment is `commission-hold-days` old (default 30). `GET /api/v1/referral/usage` rows gain `payable_after`. Affects `GET /api/v1/referral`, `GET /api/v1/referral/usage`, `GET /api/admin/v1/referrals/*` and the referral usage reports.
Expand Down
10 changes: 6 additions & 4 deletions API_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ interface Discount {
type PaymentData =
| { lightning: string } // Lightning Network invoice
| { onchain: { address: string; outpoint?: string } } // On-chain Bitcoin receive address (BTC only). `outpoint` ("{txid}:{vout}") is set as soon as a deposit is seen in the mempool (0-conf), before it confirms, so the UI can show "received, waiting for confirmation"; absent until a deposit is detected. Confirmed once is_paid is true
| { fixedfloat: { coin: string; address: string; amount: string; outpoint?: string } } // A cryptocurrency swapped to Bitcoin. Send exactly `amount` of `coin` (XMR, LTC, ETH or USDTTRC) to `address` before `expires`. A third-party exchange converts it and pays our Bitcoin wallet; `outpoint` ("{txid}:{vout}") appears when that Bitcoin is seen in the mempool. The credited time is priced from the Bitcoin actually received, so a short or late deposit buys proportionally less time. Confirmed once is_paid is true
| { revolut: { token: string } } // Revolut payment token
| { stripe: { session_id: string } }; // Stripe checkout session

Expand All @@ -535,9 +536,9 @@ interface PaymentType {
}

interface PaymentMethod {
name: 'lightning' | 'onchain' | 'revolut' | 'paypal' | 'stripe' | 'nwc' | 'lnurl';
name: 'lightning' | 'onchain' | 'fixedfloat' | 'revolut' | 'paypal' | 'stripe' | 'nwc' | 'lnurl';
metadata: Record<string, string>;
currencies: ('BTC' | 'EUR' | 'USD')[];
currencies: ('BTC' | 'EUR' | 'USD' | 'GBP' | 'CAD' | 'CHF' | 'AUD' | 'JPY' | 'XMR' | 'LTC' | 'ETH' | 'USDTTRC')[]; // For `fixedfloat` these are the coins the customer can pay with, never billing currencies
processing_fee_rate?: number; // Percentage rate (e.g., 1.0 for 1%)
processing_fee_base?: number; // Base amount in smallest currency units (cents for fiat, millisats for BTC)
processing_fee_currency?: string; // Currency for the base fee (e.g., "EUR")
Expand Down Expand Up @@ -585,7 +586,7 @@ interface SubscriptionPayment {
created: string; // ISO 8601 datetime
expires: string; // ISO 8601 datetime
amount: Price; // Total payment amount
payment_method: 'lightning' | 'onchain' | 'revolut' | 'paypal' | 'stripe' | 'nwc' | 'lnurl';
payment_method: 'lightning' | 'onchain' | 'fixedfloat' | 'revolut' | 'paypal' | 'stripe' | 'nwc' | 'lnurl';
payment_type: 'Purchase' | 'Renewal' | 'Upgrade';
is_paid: boolean;
paid_at?: string; // ISO 8601 datetime when payment was completed (only present when is_paid is true)
Expand Down Expand Up @@ -1328,7 +1329,8 @@ interface ExchangeRates {
- **GET** `/api/v1/vm/{id}/renew?method={payment_method}&intervals={count}`
- **Auth**: Required
- **Query Params**:
- `method`: Optional payment method ('lightning' | 'onchain' | 'revolut' | 'paypal' | 'nwc')
- `method`: Optional payment method ('lightning' | 'onchain' | 'fixedfloat' | 'revolut' | 'paypal' | 'nwc')
- `coin`: Required when `method=fixedfloat`: one of the `currencies` the `fixedfloat` payment method lists (`XMR`, `LTC`, `ETH`, `USDTTRC`, case-insensitive). The payment is priced in BTC and the response `data` is `{ fixedfloat: { coin, address, amount } }`. A coin that is missing, not a coin, or not listed for the company is rejected
- `intervals`: Optional number of billing intervals to renew (default: 1). For example, if the VM has a monthly billing cycle, `intervals=3` would generate a payment for 3 months.
- `code`: Optional discount code. A code that cannot be used (unknown, expired, exhausted, or not applicable to this order) **fails the request** rather than quietly invoicing full price, so the customer is told it did not work. The returned `amount` and `tax` are already net of any discount, and `discount` describes what was taken off.
- **Response**: `VmPayment`
Expand Down
6 changes: 4 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ config = { version = "0.15", features = ["yaml"] }
hex = "0.4"
nftables = "0.6"
ipnetwork = "0.21"
payments-rs = { version = "0.7.1", default-features = false }
payments-rs = { version = "0.8.0", default-features = false }
async-trait = "0.1"
futures = "0.3"
reqwest = { version = "0.13", features = ["json", "rustls-no-provider"] }
Expand Down
2 changes: 2 additions & 0 deletions lnvps_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ base64 = { version = "0.22", features = ["alloc"] }
# On-chain Bitcoin address validation for referral payouts.
bitcoin = "0.32"
urlencoding = "2.1"
hmac = "0.12"
sha2 = "0.10"
rand = "0.9"
# ES256 signing for the "Sign in with Apple" client-secret JWT (.p8 key).
p256 = { version = "0.13", features = ["ecdsa", "pkcs8", "pem"] }
Expand Down
55 changes: 55 additions & 0 deletions lnvps_api/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ pub(crate) struct PaymentMethodQuery {
/// Discount code to apply to this order. A code that cannot be used fails
/// the request rather than quietly charging full price.
pub code: Option<String>,
/// For `method=fixedfloat`: the coin the customer pays with, one of the
/// codes in the method's `coins` metadata (e.g. `XMR`).
pub coin: Option<String>,
}

impl PaymentMethodQuery {
Expand Down Expand Up @@ -234,6 +237,28 @@ pub(crate) fn resolve_quote_method(q: &PaymentMethodQuery) -> lnvps_db::PaymentM
}
}

pub(crate) fn parse_fixedfloat_coin(
coin: Option<&str>,
) -> Result<payments_rs::currency::Currency, lnvps_api_common::ApiError> {
use payments_rs::currency::Currency;
use std::str::FromStr;

let coin = coin
.map(str::trim)
.filter(|c| !c.is_empty())
.ok_or_else(|| {
lnvps_api_common::ApiError::from(anyhow::anyhow!(
"coin is required for fixedfloat payments"
))
})?;
Currency::from_str(coin)
.ok()
.filter(|c| c.is_crypto() && *c != Currency::BTC)
.ok_or_else(|| {
lnvps_api_common::ApiError::from(anyhow::anyhow!("Unsupported coin: {}", coin))
})
}

pub(crate) async fn resolve_payment_mode(
this: &RouterState,
uid: u64,
Expand Down Expand Up @@ -267,6 +292,12 @@ pub(crate) async fn resolve_payment_mode(
method_id: q.payment_method_id,
},
)),
Some("fixedfloat") => Ok((
PaymentMethod::FixedFloat,
RenewMode::FixedFloat {
coin: parse_fixedfloat_coin(q.coin.as_deref())?,
},
)),
other => Ok((
other
.and_then(|m| PaymentMethod::from_str(m).ok())
Expand All @@ -292,6 +323,7 @@ mod tests {
save_card: None,
payment_method_id: None,
code: None,
coin: None,
}
}

Expand Down Expand Up @@ -433,6 +465,29 @@ mod tests {
);
}

#[test]
fn parse_fixedfloat_coin_accepts_coins_in_any_case() {
use payments_rs::currency::Currency;

assert_eq!(parse_fixedfloat_coin(Some("XMR")).unwrap(), Currency::XMR);
assert_eq!(parse_fixedfloat_coin(Some(" ltc ")).unwrap(), Currency::LTC);
assert_eq!(parse_fixedfloat_coin(Some("eth")).unwrap(), Currency::ETH);
assert_eq!(
parse_fixedfloat_coin(Some("usdttrc")).unwrap(),
Currency::USDTTRC
);
}

#[test]
fn parse_fixedfloat_coin_rejects_missing_and_non_coins() {
assert!(parse_fixedfloat_coin(None).is_err());
assert!(parse_fixedfloat_coin(Some("")).is_err());
assert!(parse_fixedfloat_coin(Some(" ")).is_err());
assert!(parse_fixedfloat_coin(Some("BTC")).is_err());
assert!(parse_fixedfloat_coin(Some("EUR")).is_err());
assert!(parse_fixedfloat_coin(Some("DOGE")).is_err());
}

/// The wire shape mirrors the payment: amounts already net of the discount,
/// with the discount reported as a line to show rather than to re-apply.
#[test]
Expand Down
Loading
Loading