Skip to content

g1t/services/billing/src/tax.rs

277 lines11,258 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge Stripe Tax, the card fee on card payments, and one free workspace per person1//! 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}

This file's history is long; its oldest lines are credited to the oldest commit read.