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.

280 linesCodeBlameRaw
1//! Tax: what is paid with a payment on top of what reaches the workspace's
2//! 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//! - **No card fee.** Stripe's fee for taking a card is part of g1t's
9//! prices (the plan's price and the margin on usage), never a line of its
10//! own on anything. Payments from before that may still carry a
11//! `card_fee` row in `tax_and_fees`; those are kept and shown as they
12//! were, and every new payment records none.
13//! - **Tax is not revenue.** A payment's balance credit is what it paid
14//! less its tax; tax is kept in `tax_and_fees`, shown as its own line on
15//! the statement, and in sudo's Costs as tax collected, never as cash.
16//! - **No address.** When Stripe Tax cannot place a customer, g1t does not
17//! charge: it marks the account (`accounts.tax_address_needed_at`), tells
18//! the owners once, and the Billing page asks for the address. Saving
19//! billing details with one clears it.
20
21use g1t_contracts::time::rfc3339;
22use g1t_kit::now_ms;
23use serde::Deserialize;
24use worker::Result;
25
26use crate::Billing;
27
28/// A payment's tax and card fee, in cents, apart from what it paid for. The
29/// fee is only ever non-zero on payments from before it was folded into
30/// prices.
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/// What a payment's ledger line says was paid with it besides what it
57/// credited: its tax, and a card fee only on payments from before the fee
58/// was folded into prices. Nothing when there was neither.
59pub(crate) fn paid_with(extras: Extras) -> String {
60 let money = |cents: i64| crate::features::cents(cents * 10_000);
61 match (extras.tax_cents > 0, extras.fee_cents > 0) {
62 (false, false) => String::new(),
63 (true, false) => format!(" (tax {} paid with it)", money(extras.tax_cents)),
64 (false, true) => format!(" (card fee {} paid with it)", money(extras.fee_cents)),
65 (true, true) => format!(" (tax {} and card fee {} paid with it)", money(extras.tax_cents), money(extras.fee_cents)),
66 }
67}
68
69/// What the owners are told when Stripe Tax cannot place the workspace.
70pub(crate) fn address_needed_message(workspace: &str) -> String {
71 format!(
72 "Add {workspace}'s billing address in Stripe: on the Billing page (/{workspace}/-/billing#stripe), choose Manage billing. 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."
73 )
74}
75
76#[derive(Deserialize)]
77struct Sum {
78 micros: Option<f64>,
79}
80
81impl Billing {
82 /// Keeps a payment's tax and card fee apart from what it paid for, once
83 /// per payment and kind.
84 pub(crate) async fn record_extras(
85 &self,
86 workspace: &str,
87 reference: &str,
88 payment_intent: Option<&str>,
89 extras: Extras,
90 tax_transaction: Option<&str>,
91 ) -> Result<()> {
92 let writes = self.extras_statements(workspace, reference, payment_intent, extras, tax_transaction)?;
93 if !writes.is_empty() {
94 self.db.batch(writes).await?;
95 }
96 Ok(())
97 }
98
99 /// These writes can share the transaction that credits the payment.
100 pub(crate) fn extras_statements(
101 &self,
102 workspace: &str,
103 reference: &str,
104 payment_intent: Option<&str>,
105 extras: Extras,
106 tax_transaction: Option<&str>,
107 ) -> Result<Vec<worker::D1PreparedStatement>> {
108 let now = rfc3339(now_ms());
109 let mut writes = vec![];
110 for (kind, cents) in [("tax", extras.tax_cents), ("card_fee", extras.fee_cents)] {
111 if cents == 0 {
112 continue;
113 }
114 writes.push(
115 self.db
116 .prepare(
117 "INSERT OR IGNORE INTO tax_and_fees (id, workspace, kind, amount_micros, reference, payment_intent, tax_transaction, created_at)
118 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
119 )
120 .bind(&[
121 format!("{reference}/{kind}").into(),
122 workspace.into(),
123 kind.into(),
124 ((cents * 10_000) as f64).into(),
125 reference.into(),
126 crate::optional(payment_intent),
127 crate::optional(if kind == "tax" { tax_transaction } else { None }),
128 now.as_str().into(),
129 ])?,
130 );
131 }
132 Ok(writes)
133 }
134
135 /// A payment's tax and fee, as kept, by the PaymentIntent that took it
136 /// or the invoice it paid; and its tax transaction, if any.
137 pub(crate) async fn extras_of(&self, payment_intent: Option<&str>, invoice: Option<&str>) -> Result<(Extras, Option<String>)> {
138 #[derive(Deserialize)]
139 struct Row {
140 kind: String,
141 amount_micros: i64,
142 tax_transaction: Option<String>,
143 }
144 let rows = self
145 .db
146 .prepare(
147 "SELECT kind, amount_micros, tax_transaction FROM tax_and_fees
148 WHERE ((payment_intent IS NOT NULL AND payment_intent = ?1) OR reference = ?2) AND reference NOT LIKE 'refund/%'",
149 )
150 .bind(&[payment_intent.unwrap_or("").into(), invoice.unwrap_or("").into()])?
151 .all()
152 .await?
153 .results::<Row>()?;
154 let mut extras = Extras::default();
155 let mut transaction = None;
156 for row in rows {
157 match row.kind.as_str() {
158 "tax" => {
159 extras.tax_cents += row.amount_micros / 10_000;
160 transaction = transaction.or(row.tax_transaction);
161 }
162 _ => extras.fee_cents += row.amount_micros / 10_000,
163 }
164 }
165 Ok((extras, transaction))
166 }
167
168 /// Marks that Stripe Tax could not place the workspace, and tells its
169 /// owners the first time.
170 pub(crate) async fn tax_address_needed(&self, workspace: &str) -> Result<()> {
171 let now = rfc3339(now_ms());
172 #[derive(Deserialize)]
173 struct Row {
174 #[allow(dead_code)]
175 workspace: String,
176 }
177 let first = self
178 .db
179 .prepare("UPDATE accounts SET tax_address_needed_at = ? WHERE workspace = ? AND tax_address_needed_at IS NULL RETURNING workspace")
180 .bind(&[now.as_str().into(), workspace.into()])?
181 .first::<Row>(None)
182 .await?
183 .is_some();
184 if first && let Some(identity) = &self.identity {
185 crate::limits::notify_with(
186 identity,
187 workspace,
188 &format!("g1t: add a billing address for {workspace}"),
189 &address_needed_message(workspace),
190 "Add the address",
191 &format!("https://g1t.sh/{workspace}/-/billing#stripe"),
192 "You get this because you own this workspace on g1t. Tax is explained at https://docs.g1t.sh/guides/usage-and-billing/#tax",
193 )
194 .await;
195 }
196 Ok(())
197 }
198
199 /// Clears the mark once an address is saved.
200 pub(crate) async fn tax_address_given(&self, workspace: &str) -> Result<()> {
201 self.db
202 .prepare("UPDATE accounts SET tax_address_needed_at = NULL WHERE workspace = ?")
203 .bind(&[workspace.into()])?
204 .run()
205 .await?;
206 Ok(())
207 }
208
209 /// When Stripe Tax last could not place the workspace, while it still
210 /// cannot.
211 pub(crate) async fn tax_address_needed_at(&self, workspace: &str) -> Result<Option<String>> {
212 #[derive(Deserialize)]
213 struct Row {
214 tax_address_needed_at: Option<String>,
215 }
216 Ok(self
217 .db
218 .prepare("SELECT tax_address_needed_at FROM accounts WHERE workspace = ?")
219 .bind(&[workspace.into()])?
220 .first::<Row>(None)
221 .await?
222 .and_then(|row| row.tax_address_needed_at))
223 }
224
225 /// Tax and card fees kept between two days (inclusive, `YYYY-MM-DD`),
226 /// in micros, for sudo: (tax, fees).
227 pub(crate) async fn extras_between(&self, from: &str, to: &str) -> Result<(i64, i64)> {
228 let sum = |kind: &'static str| async move {
229 Ok::<i64, worker::Error>(
230 self.db
231 .prepare("SELECT SUM(amount_micros) AS micros FROM tax_and_fees WHERE kind = ? AND substr(created_at, 1, 10) BETWEEN ? AND ?")
232 .bind(&[kind.into(), from.into(), to.into()])?
233 .first::<Sum>(None)
234 .await?
235 .and_then(|s| s.micros)
236 .unwrap_or(0.0) as i64,
237 )
238 };
239 Ok((sum("tax").await?, sum("card_fee").await?))
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn a_prepayment_credits_what_it_bought_never_its_tax_or_fee() {
249 // $50 bought, $1.85 card fee, $4 tax: $50 credited.
250 assert_eq!(prepay_credit_cents(5_185, 185), 5_000);
251 assert_eq!(prepay_credit_cents(100_000, 0), 100_000);
252 assert_eq!(prepay_credit_cents(100, 500), 0);
253 }
254
255 #[test]
256 fn a_refund_gives_back_tax_and_fee_in_proportion() {
257 // $54.00 paid: $50 credit, $1.85 fee, $2.15 tax; half refunded.
258 let (balance, tax, fee) = refund_split(2_700, 5_400, 215, 185);
259 assert_eq!(balance + tax + fee, 2_700);
260 assert_eq!((tax, fee), (107, 92));
261 // A payment with neither: all of it off the balance.
262 assert_eq!(refund_split(1_000, 1_000, 0, 0), (1_000, 0, 0));
263 // Never more than was paid.
264 assert_eq!(refund_split(9_000, 5_400, 215, 185).0, 5_000);
265 }
266
267 #[test]
268 fn a_payments_line_names_a_card_fee_only_when_there_was_one() {
269 assert_eq!(paid_with(Extras::default()), "");
270 assert_eq!(paid_with(Extras { tax_cents: 180, fee_cents: 0 }), " (tax $1.80 paid with it)");
271 assert_eq!(paid_with(Extras { tax_cents: 180, fee_cents: 92 }), " (tax $1.80 and card fee $0.92 paid with it)");
272 }
273
274 #[test]
275 fn the_owners_are_told_where_to_add_the_address() {
276 let message = address_needed_message("acme");
277 assert!(message.contains("/acme/-/billing#stripe") && message.contains("Manage billing"));
278 assert!(message.contains("tax"));
279 }
280}