Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
322 linesCodeBlameRaw
1//! Tax and the card processing fee: what is paid with a payment on top of
2//! what reaches the workspace's balance.
3//!
4//! - **Tax.** Stripe Tax works it out on every Checkout page, subscription,
5//! invoice and off-session charge (`stripe::checkout_tax_fields`,
6//! `invoice_tax_fields`, `tax_calculation`), at g1t's tax code, every
7//! price excluding tax. Prices on g1t are shown before tax.
8//! - **The card fee.** Stripe's fee, grossed up (`card_fee_cents`), as a
9//! line of its own on every card payment: the plan and Security and
10//! quality (a monthly item), prepaying and invoices charged to a card. Never on a bank transfer or an invoice
11//! sent to be paid (an enterprise's). Switched by the `card_fee` cost
12//! setting, on by default. Tax applies to it as to what it is paid with.
13//! - **Neither is revenue.** A payment's balance credit is what it paid
14//! less its tax and fee; each is kept in `tax_and_fees`, shown as its own
15//! line on the statement, and in sudo's Costs as tax collected, never as
16//! cash.
17//! - **No address.** When Stripe Tax cannot place a customer, g1t does not
18//! charge: it marks the account (`accounts.tax_address_needed_at`), tells
19//! the owners once, and the Billing page asks for the address. Saving
20//! billing details with one clears it.
21
22use g1t_contracts::billing::{CardFee, MICROS_PER_DOLLAR};
23use g1t_contracts::time::rfc3339;
24use g1t_kit::now_ms;
25use serde::Deserialize;
26use worker::Result;
27
28use crate::Billing;
29
30/// A payment's tax and card fee, in cents, apart from what it paid for.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub(crate) struct Extras {
33 pub tax_cents: i64,
34 pub fee_cents: i64,
35}
36
37/// What a prepayment credits: what its lines came to before tax, less the
38/// card fee line, in cents. Never less than nothing.
39pub(crate) fn prepay_credit_cents(before_tax_cents: i64, fee_cents: i64) -> i64 {
40 (before_tax_cents - fee_cents.max(0)).max(0)
41}
42
43/// What of a refund came off the balance, and what was tax and fee given
44/// back with it: the refund split in the proportion the payment was.
45/// `extras_cents` is the payment's tax and fee together.
46pub(crate) fn refund_split(refunded_cents: i64, paid_cents: i64, tax_cents: i64, fee_cents: i64) -> (i64, i64, i64) {
47 if paid_cents <= 0 || refunded_cents <= 0 {
48 return (refunded_cents.max(0), 0, 0);
49 }
50 let refunded = refunded_cents.min(paid_cents);
51 let tax = (i128::from(refunded) * i128::from(tax_cents.max(0)) / i128::from(paid_cents)) as i64;
52 let fee = (i128::from(refunded) * i128::from(fee_cents.max(0)) / i128::from(paid_cents)) as i64;
53 (refunded - tax - fee, tax, fee)
54}
55
56/// The card fee on `credit_cents`, so that what is left after Stripe's fee
57/// is the credit: the total is `(credit + fixed) / (1 − percent)`, rounded
58/// up to the cent. None when the fee is off.
59pub(crate) fn card_fee_cents(credit_cents: u32, fee: &CardFee) -> u32 {
60 if !fee.on || credit_cents == 0 {
61 return 0;
62 }
63 let rate = fee.percent_micros / MICROS_PER_DOLLAR as f64;
64 if !(0.0..0.5).contains(&rate) {
65 return 0;
66 }
67 let total = ((f64::from(credit_cents) + f64::from(fee.fixed_cents)) / (1.0 - rate)).ceil();
68 (total as u32).saturating_sub(credit_cents)
69}
70
71/// The card fee on a card payment of `cents`, when the setting is on.
72pub(crate) fn fee_for(cents: i64, fee: &CardFee) -> i64 {
73 if cents <= 0 {
74 return 0;
75 }
76 i64::from(card_fee_cents(u32::try_from(cents).unwrap_or(u32::MAX), fee))
77}
78
79/// What the owners are told when Stripe Tax cannot place the workspace.
80pub(crate) fn address_needed_message(workspace: &str) -> String {
81 format!(
82 "Add {workspace}'s billing address under Invoice details on the Billing page (/{workspace}/-/billing#details). Stripe needs it to work out tax, so g1t did not charge the card; nothing is lost, and the charge goes through once the address is there."
83 )
84}
85
86#[derive(Deserialize)]
87struct Sum {
88 micros: Option<f64>,
89}
90
91impl Billing {
92 /// Keeps a payment's tax and card fee apart from what it paid for, once
93 /// per payment and kind.
94 pub(crate) async fn record_extras(
95 &self,
96 workspace: &str,
97 reference: &str,
98 payment_intent: Option<&str>,
99 extras: Extras,
100 tax_transaction: Option<&str>,
101 ) -> Result<()> {
102 let now = rfc3339(now_ms());
103 let mut writes = vec![];
104 for (kind, cents) in [("tax", extras.tax_cents), ("card_fee", extras.fee_cents)] {
105 if cents == 0 {
106 continue;
107 }
108 writes.push(
109 self.db
110 .prepare(
111 "INSERT OR IGNORE INTO tax_and_fees (id, workspace, kind, amount_micros, reference, payment_intent, tax_transaction, created_at)
112 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
113 )
114 .bind(&[
115 format!("{reference}/{kind}").into(),
116 workspace.into(),
117 kind.into(),
118 ((cents * 10_000) as f64).into(),
119 reference.into(),
120 crate::optional(payment_intent),
121 crate::optional(if kind == "tax" { tax_transaction } else { None }),
122 now.as_str().into(),
123 ])?,
124 );
125 }
126 if !writes.is_empty() {
127 self.db.batch(writes).await?;
128 }
129 Ok(())
130 }
131
132 /// A payment's tax and fee, as kept, by the PaymentIntent that took it
133 /// or the invoice it paid; and its tax transaction, if any.
134 pub(crate) async fn extras_of(&self, payment_intent: Option<&str>, invoice: Option<&str>) -> Result<(Extras, Option<String>)> {
135 #[derive(Deserialize)]
136 struct Row {
137 kind: String,
138 amount_micros: i64,
139 tax_transaction: Option<String>,
140 }
141 let rows = self
142 .db
143 .prepare(
144 "SELECT kind, amount_micros, tax_transaction FROM tax_and_fees
145 WHERE ((payment_intent IS NOT NULL AND payment_intent = ?1) OR reference = ?2) AND reference NOT LIKE 'refund/%'",
146 )
147 .bind(&[payment_intent.unwrap_or("").into(), invoice.unwrap_or("").into()])?
148 .all()
149 .await?
150 .results::<Row>()?;
151 let mut extras = Extras::default();
152 let mut transaction = None;
153 for row in rows {
154 match row.kind.as_str() {
155 "tax" => {
156 extras.tax_cents += row.amount_micros / 10_000;
157 transaction = transaction.or(row.tax_transaction);
158 }
159 _ => extras.fee_cents += row.amount_micros / 10_000,
160 }
161 }
162 Ok((extras, transaction))
163 }
164
165 /// The card fee, as the price book and the `card_fee` setting have it.
166 pub(crate) async fn card_fee(&self) -> Result<CardFee> {
167 #[derive(Deserialize)]
168 struct Row {
169 value: String,
170 }
171 let on = self
172 .db
173 .prepare("SELECT value FROM cost_settings WHERE key = 'card_fee'")
174 .first::<Row>(None)
175 .await?
176 .is_none_or(|row| row.value.trim() != "off");
177 let percent = self.price("card_fee_percent").await?.map_or(29_000.0, |(_, price)| price);
178 let fixed = self.price("card_fee_fixed").await?.map_or(300_000.0, |(_, price)| price);
179 Ok(CardFee { on, percent_micros: percent, fixed_cents: (fixed / 10_000.0).round().max(0.0) as u32 })
180 }
181
182 /// The card fee on a card payment, as the price book and the `card_fee`
183 /// setting have it now.
184 pub(crate) async fn card_fee_on(&self, cents: i64) -> Result<i64> {
185 Ok(fee_for(cents, &self.card_fee().await?))
186 }
187
188 /// Marks that Stripe Tax could not place the workspace, and tells its
189 /// owners the first time.
190 pub(crate) async fn tax_address_needed(&self, workspace: &str) -> Result<()> {
191 let now = rfc3339(now_ms());
192 #[derive(Deserialize)]
193 struct Row {
194 #[allow(dead_code)]
195 workspace: String,
196 }
197 let first = self
198 .db
199 .prepare("UPDATE accounts SET tax_address_needed_at = ? WHERE workspace = ? AND tax_address_needed_at IS NULL RETURNING workspace")
200 .bind(&[now.as_str().into(), workspace.into()])?
201 .first::<Row>(None)
202 .await?
203 .is_some();
204 if first && let Some(identity) = &self.identity {
205 crate::limits::notify_with(
206 identity,
207 workspace,
208 &format!("g1t: add a billing address for {workspace}"),
209 &address_needed_message(workspace),
210 "Add the address",
211 &format!("https://g1t.sh/{workspace}/-/billing#details"),
212 "You get this because you own this workspace on g1t. Tax is explained at https://docs.g1t.sh/guides/usage-and-billing/#tax",
213 )
214 .await;
215 }
216 Ok(())
217 }
218
219 /// Clears the mark once an address is saved.
220 pub(crate) async fn tax_address_given(&self, workspace: &str) -> Result<()> {
221 self.db
222 .prepare("UPDATE accounts SET tax_address_needed_at = NULL WHERE workspace = ?")
223 .bind(&[workspace.into()])?
224 .run()
225 .await?;
226 Ok(())
227 }
228
229 /// When Stripe Tax last could not place the workspace, while it still
230 /// cannot.
231 pub(crate) async fn tax_address_needed_at(&self, workspace: &str) -> Result<Option<String>> {
232 #[derive(Deserialize)]
233 struct Row {
234 tax_address_needed_at: Option<String>,
235 }
236 Ok(self
237 .db
238 .prepare("SELECT tax_address_needed_at FROM accounts WHERE workspace = ?")
239 .bind(&[workspace.into()])?
240 .first::<Row>(None)
241 .await?
242 .and_then(|row| row.tax_address_needed_at))
243 }
244
245 /// Tax and card fees kept between two days (inclusive, `YYYY-MM-DD`),
246 /// in micros, for sudo: (tax, fees).
247 pub(crate) async fn extras_between(&self, from: &str, to: &str) -> Result<(i64, i64)> {
248 let sum = |kind: &'static str| async move {
249 Ok::<i64, worker::Error>(
250 self.db
251 .prepare("SELECT SUM(amount_micros) AS micros FROM tax_and_fees WHERE kind = ? AND substr(created_at, 1, 10) BETWEEN ? AND ?")
252 .bind(&[kind.into(), from.into(), to.into()])?
253 .first::<Sum>(None)
254 .await?
255 .and_then(|s| s.micros)
256 .unwrap_or(0.0) as i64,
257 )
258 };
259 Ok((sum("tax").await?, sum("card_fee").await?))
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 fn fee(on: bool) -> CardFee {
268 CardFee { on, percent_micros: 29_000.0, fixed_cents: 30 }
269 }
270
271 #[test]
272 fn the_card_fee_is_stripes_fee_grossed_up_and_off_when_switched_off() {
273 // $25: ($25 + $0.30) / 0.971 = $26.06, so a $1.06 fee.
274 assert_eq!(card_fee_cents(2_500, &fee(true)), 106);
275 // What is left after Stripe's 2.9% + 30¢ is at least the amount.
276 for credit in [1_000u32, 2_500, 5_000, 10_000, 100_000] {
277 let total = credit + card_fee_cents(credit, &fee(true));
278 let net = f64::from(total) - (f64::from(total) * 0.029).round() - 30.0;
279 assert!(net >= f64::from(credit) - 1.0, "{credit}: {total} leaves {net}");
280 }
281 assert_eq!(card_fee_cents(2_500, &fee(false)), 0);
282 assert_eq!(card_fee_cents(0, &fee(true)), 0);
283 }
284
285 #[test]
286 fn a_prepayment_credits_what_it_bought_never_its_tax_or_fee() {
287 // $50 bought, $1.85 card fee, $4 tax: $50 credited.
288 assert_eq!(prepay_credit_cents(5_185, 185), 5_000);
289 assert_eq!(prepay_credit_cents(100_000, 0), 100_000);
290 assert_eq!(prepay_credit_cents(100, 500), 0);
291 }
292
293 #[test]
294 fn a_refund_gives_back_tax_and_fee_in_proportion() {
295 // $54.00 paid: $50 credit, $1.85 fee, $2.15 tax; half refunded.
296 let (balance, tax, fee) = refund_split(2_700, 5_400, 215, 185);
297 assert_eq!(balance + tax + fee, 2_700);
298 assert_eq!((tax, fee), (107, 92));
299 // A payment with neither: all of it off the balance.
300 assert_eq!(refund_split(1_000, 1_000, 0, 0), (1_000, 0, 0));
301 // Never more than was paid.
302 assert_eq!(refund_split(9_000, 5_400, 215, 185).0, 5_000);
303 }
304
305 #[test]
306 fn the_card_fee_is_on_card_payments_unless_switched_off() {
307 // A $20 plan: $0.91, so that $20 is left after Stripe's 2.9% + 30¢.
308 assert_eq!(fee_for(2_000, &fee(true)), 91);
309 assert_eq!(fee_for(2_000, &fee(false)), 0);
310 assert_eq!(fee_for(0, &fee(true)), 0);
311 assert_eq!(fee_for(-500, &fee(true)), 0);
312 // The default is on: a setting never written reads as on.
313 assert!(g1t_contracts::billing::CostSettings::default().card_fee);
314 }
315
316 #[test]
317 fn the_owners_are_told_where_to_add_the_address() {
318 let message = address_needed_message("acme");
319 assert!(message.contains("/acme/-/billing#details"));
320 assert!(message.contains("tax"));
321 }
322}