Skip to content

g1t/services/billing/src/tax.rs

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