Skip to content

Commit

Site, sudo, API and docs: tax, card fees and free workspace limits

Prices say they exclude tax, the card fee is shown before paying, Billing asks for a missing address and shows tax and card fees as their own lines, New workspace, People and Access say Start the plan to invite people, sudo shows Tax collected and takes an enterprise's billing address. API and MCP descriptions, the billing guide, BILLING_OPERATIONS.md and PLAN.md.

syntaqxcommitted Parenta3c5f7fBrowse files
26 files+807−920/26 viewed
+6−6
10201020 "Who the access token acts as, and the workspaces it can work in. `kind` is `user` for a person's token, `workspace` for a token that belongs to a workspace, and `agent` for the token a g1t agent works with."
10211021 }
10221022 Op::CreateWorkspace => {
1023− "Create a workspace. A workspace owns repositories and is the first part of their address: g1t.sh/{workspace}/{repo}. The whoami tool lists the ones you already belong to."
1023+ "Create a workspace. A workspace owns repositories and is the first part of their address: g1t.sh/{workspace}/{repo}. The whoami tool lists the ones you already belong to. A new workspace is free, and each person can own one free workspace: if you already own one (or several, from before), this is refused with `payment_required` (402) until each workspace you own is on the g1t plan or deleted. Workspaces with the plan, an enterprise's terms or a full discount do not count."
10241024 }
10251025 Op::ListEmails => {
10261026 "Your email addresses: each one's `email`, whether it is `verified` (confirmed), `primary` or the `backup`, and when it was added and confirmed. Also whether you keep your address private (`private_email`), your `noreply` address, and `commit_email`, the address on commits g1t makes for you. People only: an agent's or a workspace's token cannot read or change addresses."
10471047 "The invites made for a workspace, newest first, with each pending one's `code`. Owners only."
10481048 }
10491049 Op::InviteMember => {
1050− "Invite an email address into a workspace. It always makes an invite bound to that address and emails it the link, so the answer never says whether the address has a g1t account. Without one, accepting makes the account and joins the workspace in one step, and uses one of the workspace's granted invites, or else one of yours. With one, it costs nothing, and they join when they accept. To add someone by username at once, use the workspace's People page. Owners only."
1050+ "Invite an email address into a workspace. It always makes an invite bound to that address and emails it the link, so the answer never says whether the address has a g1t account. Without one, accepting makes the account and joins the workspace in one step, and uses one of the workspace's granted invites, or else one of yours. With one, it costs nothing, and they join when they accept. To add someone by username at once, use the workspace's People page. Owners only. A free workspace cannot invite anyone: this is refused with `payment_required` (402) until it starts the g1t plan, and an invite sent before cannot be accepted until then."
10511051 }
10521052 Op::RevokeWorkspaceInvite => "Revoke a workspace's pending invite. Owners only.",
10531053 Op::DeleteWorkspace => {
13341334 "Who has access to a repository: the workspace's `base_permission`, and `people`, everyone with a role on it other than through it being public. Each person has their effective `role` (read, triage, write, maintain or admin), its `source` (`owner` of the workspace, the workspace's `base` permission, or a `direct` role on this repository), their `direct` role if they have one, and their `workspace_role` (`owner`, `member`, or null for an outside collaborator). Pending `invitations` are listed for those with the Admin role, and empty for anyone else. `viewer_role` is your own role, and `can_manage` whether you may change who has access. Needs the Write role or higher. People only."
13351335 }
13361336 Op::AddCollaborator => {
1337− "Give someone a role on a repository, by username or email address. A member of its workspace gets the role at once (`result` is `granted`, with the `collaborator`). Anyone else becomes an outside collaborator once they accept an invitation, which is emailed to them and waits 7 days (`result` is `invited`, with the `invitation`); an address with no g1t account is sent an invite that makes the account and accepts in one step. The role is read, triage, write, maintain or admin. Needs the Admin role on the repository, signed in as a person with a confirmed email address; agents' and workspaces' tokens are refused."
1337+ "Give someone a role on a repository, by username or email address. A member of its workspace gets the role at once (`result` is `granted`, with the `collaborator`). Anyone else becomes an outside collaborator once they accept an invitation, which is emailed to them and waits 7 days (`result` is `invited`, with the `invitation`); an address with no g1t account is sent an invite that makes the account and accepts in one step. The role is read, triage, write, maintain or admin. Needs the Admin role on the repository, signed in as a person with a confirmed email address; agents' and workspaces' tokens are refused. A free workspace can give its members a role, but cannot invite anyone from outside it: that is refused with `payment_required` (402) until the workspace starts the g1t plan."
13381338 }
13391339 Op::UpdateCollaborator => {
13401340 "Change the role someone was given on a repository directly, or the role of their pending invitation. A role from ownership or the workspace's base permission is not changed here: an owner always has Admin, and a member never has less than the base permission. Needs the Admin role. People only."
13551355 "The invitations to repositories waiting for you to answer, sent to your username or to one of your confirmed email addresses, newest first. Accept or decline each by its `id`. People only; an agent's or a workspace's token gets an empty list."
13561356 }
13571357 Op::AcceptRepoInvitation => {
1358− "Accept an invitation to a repository sent to you. You get its role on that repository at once, as an outside collaborator unless you belong to its workspace. Refused when the workspace asks something of everyone with access that your account does not meet, such as two-factor authentication. People only."
1358+ "Accept an invitation to a repository sent to you. You get its role on that repository at once, as an outside collaborator unless you belong to its workspace. Refused when the workspace asks something of everyone with access that your account does not meet, such as two-factor authentication, and with `payment_required` (402) while the workspace is free: it can add no one until it starts the g1t plan, and the invitation waits until then. People only."
13591359 }
13601360 Op::DeclineRepoInvitation => {
13611361 "Decline an invitation to a repository sent to you. Whoever sent it can invite you again. People only."
14841484 "Start buying AI credit. Returns `url`, a payment page to open in a browser and pay by card; it comes back to the workspace's billing page. `amount_cents` is the credit, in whole dollars from $10 (1000) to $1,000 (100000); any card fee is added on top. The credit is added once the payment goes through. Owners only, as a person: signed in or with a personal access token. A workspace's own token and g1t's agents never buy credit."
14851485 }
14861486 Op::ListInvoices => {
1487− "A workspace's invoices, newest first. `invoices` is every invoice billed to it (the plan, activations, AI credit and usage), each with its `status`, `total_cents`, `currency` and links to view it and its PDF. `usage_invoices` are g1t's itemised invoices for usage, one when each month closes and one each time the card is charged near the limit, with their `lines` in millionths of a dollar. `upcoming` is what the next invoice comes to so far. `unavailable` says why `invoices` could not be read just now, when it could not. Members of the workspace only."
1487+ "A workspace's invoices, newest first. `invoices` is every invoice billed to it (the plan, activations, AI credit and usage), each with its `status`, `total_cents`, `currency` and links to view it and its PDF. `usage_invoices` are g1t's itemised invoices for usage, one when each month closes and one each time the card is charged near the limit, with their `lines` in millionths of a dollar; `amount_micros` is the usage, and the card processing fee (`fee_micros`) and tax (`tax_micros`) are on top. Prices exclude tax: Stripe adds it where it applies. `upcoming` is what the next invoice comes to so far. `unavailable` says why `invoices` could not be read just now, when it could not. Members of the workspace only."
14881488 }
14891489 Op::GetBillingDetails => {
1490− "Who a workspace's invoices are made out to: the billing `email`, `name`, `address`, tax ID (`tax_id_type`, `tax_id`), `po_number` and the invoices' `language`, with the default `payment_method` as far as it is safe to show (its kind, brand, last four digits and expiry). `customer` is false until the workspace has been set up to pay. Members of the workspace only."
1490+ "Who a workspace's invoices are made out to: the billing `email`, `name`, `address`, tax ID (`tax_id_type`, `tax_id`), `po_number` and the invoices' `language`, with the default `payment_method` as far as it is safe to show (its kind, brand, last four digits and expiry). `customer` is false until the workspace has been set up to pay. Tax is worked out from the address: `tax_location` says whether it is enough for that (a country, and in the US a ZIP code), `tax_address_needed_at` is set while g1t is holding a charge for want of one, `tax_id_status` is Stripe's check of the tax ID (`pending`, `verified`, `unverified` or `unavailable`), and `tax_exempt` is `none`, `exempt` or `reverse`. Members of the workspace only."
14911491 }
14921492 Op::ListUserTeams => {
14931493 "The teams someone is in within a workspace, as list_teams describes them, leaving out secret teams you cannot see. Members of the workspace only."
+12−6
6868 "member_count": 1,
6969 "base_permission": "write"
7070 },
71− "notes": "`base_permission` is what every member gets on each of its repositories: `write` until an owner changes it with `PATCH /workspaces/{workspace}` or `PUT /workspaces/{workspace}/base_permission`. See [Access and roles](/guides/access-and-roles/)."
71+ "notes": "`base_permission` is what every member gets on each of its repositories: `write` until an owner changes it with `PATCH /workspaces/{workspace}` or `PUT /workspaces/{workspace}/base_permission`. See [Access and roles](/guides/access-and-roles/). A new workspace is free, and each person owns at most one free workspace: while you own one, this answers `402` with `payment_required` and says which, until it is on the plan or deleted. See [One free workspace per person](/guides/usage-and-billing/#one-free-workspace-per-person)."
7272 },
7373 "update_workspace": {
7474 "params": {
402402 "redeemed_at": null,
403403 "revoked_at": null
404404 },
405− "notes": "`kind` is `workspace` when the address already has a g1t account (`charged_to` is then `none`), and `account` when accepting makes one. Both answers look alike to the caller on purpose: the invite is emailed either way."
405+ "notes": "`kind` is `workspace` when the address already has a g1t account (`charged_to` is then `none`), and `account` when accepting makes one. Both answers look alike to the caller on purpose: the invite is emailed either way. A free workspace cannot invite anyone: `402` with `payment_required`, until it starts the g1t plan."
406406 },
407407 "revoke_workspace_invite": {
408408 "response": {
41644164 "expires_at": "2026-10-12T17:00:00.000Z"
41654165 }
41664166 },
4167− "notes": "`invitee` is a username or an email address. A member of the workspace answers `{\"result\": \"granted\", \"collaborator\": {…}}` with the role already given, shown as list_collaborators shows a person. Anyone else answers `{\"result\": \"invited\", \"invitation\": {…}}`: the invitation is emailed and waits 7 days, and the role is theirs once they accept it. An email address without an account gets an invitation with `email` set and `invitee` null, and an invite that makes the account and accepts in one step. Refused with `404` when no account has that username, `409` when they already have a role of their own or a pending invitation (change it with update_collaborator), and `403` without the Admin role, from an agent's or a workspace's token, or when the person does not meet what the workspace asks of everyone with access. The `repo.collaborator_added` webhook event is sent once they have the role. See [Access and roles](/guides/access-and-roles/)."
4167+ "notes": "`invitee` is a username or an email address. A member of the workspace answers `{\"result\": \"granted\", \"collaborator\": {…}}` with the role already given, shown as list_collaborators shows a person. Anyone else answers `{\"result\": \"invited\", \"invitation\": {…}}`: the invitation is emailed and waits 7 days, and the role is theirs once they accept it. An email address without an account gets an invitation with `email` set and `invitee` null, and an invite that makes the account and accepts in one step. Refused with `404` when no account has that username, `409` when they already have a role of their own or a pending invitation (change it with update_collaborator), and `403` without the Admin role, from an agent's or a workspace's token, or when the person does not meet what the workspace asks of everyone with access. On a free workspace, inviting anyone who is not a member answers `402` with `payment_required` until it starts the g1t plan. The `repo.collaborator_added` webhook event is sent once they have the role. See [Access and roles](/guides/access-and-roles/)."
41684168 },
41694169 "update_collaborator": {
41704170 "params": {
59615961 "amount_micros": 6200000
59625962 }
59635963 ],
5964− "created_at": "2026-10-01T00:05:00.000Z"
5964+ "created_at": "2026-10-01T00:05:00.000Z",
5965+ "fee_micros": 1600000,
5966+ "tax_micros": 3510000
59655967 }
59665968 ],
59675969 "upcoming": {
60006002 "last4": "4242",
60016003 "exp_month": 12,
60026004 "exp_year": 2028
6003− }
6005+ },
6006+ "tax_location": true,
6007+ "tax_address_needed_at": null,
6008+ "tax_id_status": "unavailable",
6009+ "tax_exempt": "none"
60046010 },
6005− "notes": "Owners change these on the workspace's billing page. `payment_method` is null until a card or other way to pay is saved."
6011+ "notes": "Owners change these on the workspace's billing page. `payment_method` is null until a card or other way to pay is saved. Tax is worked out from the address: `tax_location` is false until it has at least a country (and in the US a ZIP code), and `tax_address_needed_at` is set while a charge waits for one."
60066012 },
60076013 "list_pinned_projects": {
60086014 "params": {
+7−0
163163 granted invites, or else one of yours (see
164164 [invites](/guides/authentication/#invites)), and works for 30 days.
165165
166+On a free workspace, only the first row works: its members can be given a
167+role, but nobody else can be invited until the workspace starts the g1t
168+plan. **Add people** says **Start the plan to invite people** above the
169+form, and an invitation is refused with `402` (`payment_required`). An
170+invitation sent before cannot be accepted until then. See
171+[who a free workspace can add](/guides/usage-and-billing/#who-a-free-workspace-can-add).
172+
166173 To change someone's role, pick another beside their name. To take it away,
167174 choose **Remove**. Removing takes away only the role given on this
168175 repository: an owner's Admin, a member's base permission and what their
+119−9
3737 deployments and semantic search need [the g1t plan](#the-g1t-plan), or a
3838 [card check](#no-card-no-compute) for the trial and the open-source pool.
3939
40+### One free workspace per person
41+
42+Each person can own **one free workspace**: a workspace that is not on the
43+g1t plan, an enterprise's terms or a full discount. A new workspace starts
44+free, so while you own a free workspace, creating another is refused with
45+the way forward:
46+
47+1. Start the plan on the free workspace you have
48+ ([Start or end the plan](#start-or-end-the-plan)), or
49+2. delete it, if you no longer use it
50+ ([Delete a workspace](/guides/workspaces/)).
51+
52+Then create the new one. If you owned several free workspaces before this
53+rule, you keep them all, but you cannot create another until each of them
54+is on the plan or deleted. Workspaces you belong to without owning them do
55+not count. The site's **New workspace** page says so before you start;
56+`POST /workspaces` and the MCP `workspace` tool's `create` action answer
57+`402` (`payment_required`) with the same message.
58+
59+### Who a free workspace can add
60+
61+A free workspace keeps the people already in it, but **cannot add anyone**
62+until it starts the plan:
63+
64+- no new members, by username or by email invite;
65+- no outside collaborators on its repositories, and no invitations to them;
66+- an invite or invitation sent before cannot be accepted until then (it
67+ waits, and works once the plan is on).
68+
69+Its members can still be given a role on its repositories, and put on its
70+[teams](/guides/teams/). On the **People** page and a repository's
71+**Settings → Access**, an owner sees **Start the plan to invite people**
72+with a button to the plan. Through the API and MCP, adding a member,
73+inviting, adding an outside collaborator and accepting are refused with
74+`402` (`payment_required`). g1t's own agent, `@g1t`, works in every
75+workspace and never counts as someone added.
76+
4077 Your own machines are. Workflow jobs on
4178 [self-hosted runners](/guides/self-hosted-runners/) cost nothing, on every
4279 plan, the free one included, and need no card; their minutes show on usage
4683 ## The g1t plan
4784
4885 One plan, **$20 a month per workspace**, however many people and agents
49−are in it. It is never priced per person.
86+are in it. It is never priced per person. Like every price on g1t, it
87+excludes tax ([Tax](#tax)), and paid by card it carries Stripe's
88+[card processing fee](#card-processing-fee) as its own line, $0.91 a month
89+on $20.
5090
5191 - **$10 of usage each month** at cost plus 20%, used first.
5292 - **Everyone in the workspace** at one price, never per person.
514554 asks. A bank transfer page gives the account details, and the amount
515555 counts when the money arrives.
516556
557+By card, the [card processing fee](#card-processing-fee) is its own line
558+(the card shows it for $100, $500 and $1,000); a bank transfer has none.
559+[Tax](#tax) is added where it applies. What you prepay is credited in full:
560+neither the fee nor the tax comes from it.
561+
517562 **Prepaid balance** shows what is paid in advance and not used yet.
518563
519564 ### Caps
585630 amount from $10 to $1,000.
586631 3. Choose **Buy AI credit**, and pay on Stripe's page.
587632
588−Stripe's card fee (2.9% + $0.30) is its own line on that page, **Card
589−processing fee**, so the credit you get is the amount you chose. Invoiced
590−billing never carries it. The credit is added once Stripe says the payment
591−was made, whether or not you come back to g1t, and **expires 1 year after
592−purchase**. The card is kept for auto-reload.
633+The [card processing fee](#card-processing-fee) is its own line on that
634+page, and shown on Billing before you go there (on $25, *Card processing
635+fee $1.06, plus tax where it applies*), so the credit you get is the amount
636+you chose. [Tax](#tax) is added on top where it applies. The credit is
637+added once Stripe says the payment was made, whether or not you come back
638+to g1t, and **expires 1 year after purchase**. The card is kept for
639+auto-reload, which charges the credit, its card fee and its tax together.
593640
594641 ### Auto-reload
595642
658705 charged at once with no minimum. See
659706 [deleting a workspace](/guides/workspaces/#what-billing-needs).
660707
661−Each is charged to the card on file. The Billing page lists them, with
662−links to view each on Stripe and download its PDF.
708+Each is charged to the card on file, with the
709+[card processing fee](#card-processing-fee) and [tax](#tax) as lines of
710+their own. The Billing page lists them, with links to view each on Stripe
711+and download its PDF; each one's amount is the usage, with the fee and
712+tax under it.
713+
714+## Tax
715+
716+Every price on g1t excludes tax. Stripe adds sales tax, VAT or GST where it
717+applies (Stripe Tax), worked out from the workspace's billing address, and
718+shows it as its own line before you pay and on every receipt and invoice.
719+The plan, add-ons, prepaying, AI credit, auto-reload and invoices are all
720+taxed the same way, as software as a service for business use.
721+
722+- **The address.** Stripe's payment pages always ask for a billing
723+ address, and save it as the workspace's **Invoice details**. A card check
724+ saves the card's billing address there too, if there is none yet. You
725+ can change it under [Your card and billing
726+ details](#your-card-and-billing-details).
727+- **A business tax ID.** Add it on Stripe's page or under **Invoice
728+ details** (the kind, such as EU VAT, and the number). Stripe checks it
729+ (**Verified by Stripe**, or **Stripe is checking it**) and applies it
730+ where the law says so, such as a reverse charge.
731+- **No address, no charge.** Where Stripe has nothing to work tax out
732+ from, g1t does not charge the card. Billing shows **Add a billing
733+ address**, the owners are emailed once, and the charge goes through once
734+ the address is saved. Nothing is lost, and work is not stopped for it.
735+ In the United States the ZIP code is needed; elsewhere the country.
736+- **Tax exempt.** If your organisation is exempt, write to
737+ support@g1t.sh with the certificate; Billing then says **Tax exempt**.
738+- **Refunds** give the tax back in proportion.
739+
740+Tax is never part of your balance or usage: the statement shows it as its
741+own line, beside the payment it came with.
742+
743+## Card processing fee
744+
745+Paying by card adds Stripe's fee, **2.9% + $0.30**, as its own line,
746+**Card processing fee**, worked out so that what is left after Stripe's
747+fee is exactly what you paid for: $0.91 on the $20 plan, $1.06 on $25 of
748+AI credit. It is shown before you pay, on every card payment:
749+
750+| Payment | Card fee |
751+| --- | --- |
752+| The plan and add-ons, each month | Yes, a monthly line |
753+| Prepaying by card, and AI credit | Yes |
754+| Auto-reload, and invoices charged to the card | Yes |
755+| Prepaying by bank transfer | No |
756+| An enterprise's invoices | No |
757+
758+Tax applies to the fee as to what it is paid with. The fee pays Stripe, not
759+g1t: usage is still charged at cost plus 20%, and models at the provider's
760+price plus the agent rate. The statement shows card fees as their own line,
761+never from your balance.
663762
664763 ### The minimum charge
665764
691790 printed on every invoice: the invoice email, company name, billing
692791 address, tax ID (its kind and number), a purchase order, and the invoice
693792 language. An owner edits them here and chooses **Save invoice details**.
793+ [Tax](#tax) is worked out from the address, so the card says when there
794+ is none yet (in the US it needs the ZIP code), shows Stripe's check of
795+ the tax ID, and says **Tax exempt** or **Reverse charge** when that
796+ applies.
694797 - **Invoices** lists every invoice Stripe sent (the plan, add-ons, AI
695798 credit and month-end usage), each with **View** and **PDF**.
696799 - **Add-ons** lists what can be turned on beside the plan, such as the
746849 enterprise is **invoiced**: when each month closes, one invoice goes to
747850 the enterprise's billing address, with a line for each workspace, due in
748851 30 days and paid by card or bank transfer. If it goes overdue, the
749− workspaces' work stops until it is paid.
852+ workspaces' work stops until it is paid. [Tax](#tax) is added from the
853+ enterprise's billing address, which g1t keeps on its Stripe customer, and
854+ an invoice never carries the card processing fee.
750855 - **A discount**: a percentage off every usage charge, from a few percent
751856 to 100%, with a reason, sometimes until a date. Prices themselves stay
752857 the public ones. The statement shows every line at its price and the
844949 | Payments | Card payments and invoices paid. |
845950 | Credits from g1t | Credit g1t added (promotional, goodwill or a refund), and what of it expired or was withdrawn. See [Credits from g1t](#credits-from-g1t). |
846951 | Refunds | Money given back to your card. |
952+ | Tax | Tax paid with that day's payments. Not a charge, and not from your balance. |
953+ | Card processing fees | Card fees paid with that day's payments. Not a charge, and not from your balance. |
954+
955+ Payments are what reached your balance; the tax and card fee paid with
956+ them are the two lines below them, and the totals say what they came to.
847957
848958 - **Covered.** Below the totals, what paid for usage before it was
849959 charged, each with its amount:
+15−0
3232 You can belong to up to ten workspaces. `GET /user`, or the `account` tool's `whoami` action, lists the
3333 ones you belong to.
3434
35+A new workspace is free, and **each person can own one free workspace**.
36+While you own a free workspace, the page shows **You already own a free
37+workspace** in place of the form, with **Start the plan** on it; the API
38+answers `402` (`payment_required`). Start the plan on it, or delete it,
39+then create the new one. Several free workspaces from before are kept, but
40+each needs the plan (or deleting) before you can create another. See
41+[one free workspace per person](/guides/usage-and-billing/#one-free-workspace-per-person).
42+
3543 Usernames and workspaces share one set of names, so a name means the same
3644 thing wherever it appears. Your username is reserved for you: only you can
3745 create a workspace with that name, and nobody can register a username that
295303 To give someone a role on one repository without making them a member,
296304 add them as an [outside collaborator](/guides/access-and-roles/#outside-collaborators).
297305
306+**A free workspace cannot add people.** Until it starts the g1t plan, it
307+cannot add members, send invites, or invite outside collaborators, and an
308+invite sent before waits until the plan is on. Its members stay. People
309+shows **Start the plan to invite people** with the button in place of the
310+form, and the API and MCP answer `402` (`payment_required`). See
311+[who a free workspace can add](/guides/usage-and-billing/#who-a-free-workspace-can-add).
312+
298313 ## The workspace's page
299314
300315 A workspace's own page, `g1t.sh/<workspace>`, has its icon, name, address
+3−3
489489 | --- | --- | --- | --- |
490490 | [`list_collaborators`](/reference/api/access/list-collaborators/) | Everyone with a role on it, with the role, where it comes from (`owner`, `base` or `direct`) and whether they are members; the base permission; and, with the Admin role, pending invitations. Needs the Write role. | `repo` | `access:read` |
491491 | [`get_permission`](/reference/api/access/get-collaborator-permission/) | Someone's role, where it comes from, and what it lets them do. Needs the Write role, or to be about yourself. | `repo`, `username` | `access:read` |
492−| [`add_collaborator`](/reference/api/access/add-collaborator/) | Give someone a role by username or email address. A member gets it at once; anyone else is invited, and becomes an outside collaborator on accepting. Needs the Admin role. | `repo`, `invitee`, `role` | `access:admin` |
492+| [`add_collaborator`](/reference/api/access/add-collaborator/) | Give someone a role by username or email address. A member gets it at once; anyone else is invited, and becomes an outside collaborator on accepting. Needs the Admin role. On a free workspace, only members: inviting anyone else is refused with `402` until it starts the plan. | `repo`, `invitee`, `role` | `access:admin` |
493493 | [`update_collaborator`](/reference/api/access/update-collaborator/) | Change someone's direct role, or their pending invitation's. Needs the Admin role. | `repo`, `username`, `role` | `access:admin` |
494494 | [`remove_collaborator`](/reference/api/access/remove-collaborator/) | Take away someone's direct role. Needs the Admin role, or to be your own. | `repo`, `username` | `access:admin` |
495495 | [`list_invitations`](/reference/api/access/list-repo-invitations/) | Its pending invitations. Needs the Admin role. | `repo` | `access:read` |
532532
533533 | Action | What it does | Required | Scope |
534534 | --- | --- | --- | --- |
535−| [`create`](/reference/api/workspaces/create-workspace/) | Create a workspace. | `slug` | `workspace:admin` |
535+| [`create`](/reference/api/workspaces/create-workspace/) | Create a workspace. A new one is free, and each person owns at most one free workspace: refused with `402` while you own one, until it is on the plan or deleted. See [one free workspace per person](/guides/usage-and-billing/#one-free-workspace-per-person). | `slug` | `workspace:admin` |
536536 | [`update`](/reference/api/workspaces/update-workspace/) | Change its display name and description, and with the `access:admin` scope too, its `base_permission`. Only the fields given change; the slug never does. Owners only. | `workspace` | `workspace:admin` |
537537 | [`delete`](/reference/api/workspaces/delete-workspace/) | Delete an empty workspace whose billing is settled; `confirm` is its slug. Owners only. See [deleting a workspace](/guides/workspaces/#delete-a-workspace). | `workspace`, `confirm` | `workspace:admin` |
538538 | [`list_invites`](/reference/api/invites/list-workspace-invites/) | A workspace's invites. Owners only. | `workspace` | `workspace:read` |
539−| [`invite_member`](/reference/api/invites/invite-member/) | Invite an address into a workspace, with an invite bound to it. Owners only. | `workspace`, `email` | `workspace:admin` |
539+| [`invite_member`](/reference/api/invites/invite-member/) | Invite an address into a workspace, with an invite bound to it. Owners only. A free workspace cannot invite: refused with `402` until it starts the plan. | `workspace`, `email` | `workspace:admin` |
540540 | [`revoke_invite`](/reference/api/invites/revoke-workspace-invite/) | Revoke a workspace's pending invite. Owners only. | `workspace`, `id` | `workspace:admin` |
541541 | [`list_integrations`](/reference/api/integrations/list-integrations/) | The workspace's connections. Secrets are never returned. Members only. | `workspace` | `workspace:read` |
542542 | [`connect_integration`](/reference/api/integrations/connect-integration/) | Connect a model provider (Anthropic, OpenAI, Gemini, or a compatible endpoint), Sentry, Datadog, a webhook, Jira or Linear, with `config` and `secret`. Owners only. | `workspace`, `provider` | `workspace:admin` |
+0−0

Binary or large file; its contents are not shown.

+24−0
201201 return back("billing-email");
202202 }
203203
204+ if (intent === "billing-address") {
205+ // Where Stripe Tax places the enterprise: an invoice is not sent without it.
206+ const values = fields(form, "line1", "line2", "city", "state", "postalCode", "country", "taxIdType", "taxId");
207+ if (subject.kind !== "enterprise") return failed("top", "Only an enterprise's address is set here; a workspace's owners set their own.");
208+ if (!subject.billingEmail) return failed("address", "Set where its invoices go first: that makes its Stripe customer.", values);
209+ const country = values.country.trim().toUpperCase();
210+ if (!/^[A-Z]{2}$/.test(country)) return failed("address", "The country is two letters, such as US or DE.", values);
211+ if (country === "US" && !values.postalCode.trim()) return failed("address", "In the US, Stripe Tax needs the ZIP code.", values);
212+ const taxIdType = values.taxIdType.trim();
213+ const taxId = values.taxId.trim();
214+ if (Boolean(taxIdType) !== Boolean(taxId)) return failed("address", "Give the tax ID's kind and its number together, or neither.", values);
215+ const address = {
216+ line1: values.line1.trim(),
217+ line2: values.line2.trim(),
218+ city: values.city.trim(),
219+ state: values.state.trim(),
220+ postalCode: values.postalCode.trim(),
221+ country,
222+ };
223+ const result = await admin.enterpriseAddress(subject.accountId, address, taxIdType || null, taxId || null, staff.email);
224+ if (!result.ok) return failed("address", result.error.message, values);
225+ return back("billing-address");
226+ }
227+
204228 if (intent === "invoice") {
205229 if (subject.kind !== "enterprise") return failed("top", "Only an enterprise is invoiced from sudo.");
206230 if (!confirmed) {
+1−0
3333 "reset-stale": "Billing reset. The workspace starts again as a new customer; the costs analysis did not finish, so press Run the analysis now on Costs & margin.",
3434 created: "Enterprise created.",
3535 "billing-email": "Saved where the enterprise's invoices go.",
36+ "billing-address": "Billing address saved on its Stripe customer. Its invoices are taxed from it.",
3637 sales: "Sales record saved.",
3738 note: "Note added.",
3839 payment: "Payment recorded. It is on the workspace's statement and counts toward its limit.",
+2−2
257257 <Field label="And at least, $">
258258 <Input name="anomalyFloor" inputMode="decimal" defaultValue={dollarsField(report.settings.anomalyFloorMicros)} />
259259 </Field>
260− <Field label="Card fee on AI credit bought by card">
260+ <Field label="Card fee on card payments">
261261 <label className="flex h-[38px] items-center gap-2 text-sm text-fg-soft">
262− <input type="checkbox" name="cardFee" defaultChecked={report.settings.cardFee ?? true} className="accent-[var(--g1t-merged)]" /> Passed on as its own line
262+ <input type="checkbox" name="cardFee" defaultChecked={report.settings.cardFee ?? true} className="accent-[var(--g1t-merged)]" /> Its own line; never on invoiced billing or bank transfers
263263 </label>
264264 </Field>
265265 <div className="sm:col-span-2 lg:col-span-4">
+12−0
353353 Every credit
354354 </Link>
355355 </p>
356+ <dl className="mt-3 grid grid-cols-2 gap-3 rounded-lg border border-line bg-surface px-4 py-3 text-sm sm:px-5">
357+ <div>
358+ <dt className="text-xs text-muted">Tax collected</dt>
359+ <dd className="tabular text-fg">{usd(o.taxCollectedMicros ?? 0, { cents: true })}</dd>
360+ <dd className="text-xs text-faint">Owed to tax authorities; never in money in or margin. Filed from Stripe Tax.</dd>
361+ </div>
362+ <div>
363+ <dt className="text-xs text-muted">Card fees passed on</dt>
364+ <dd className="tabular text-fg">{usd(o.cardFeesMicros ?? 0, { cents: true })}</dd>
365+ <dd className="text-xs text-faint">Paid Stripe&apos;s card fees; not revenue either.</dd>
366+ </div>
367+ </dl>
356368 </>
357369 );
358370 }
+43−1
1−import { ArrowLeft, ExternalLink, Mail, Plus, Send, Trash2 } from "lucide-react";
1+import { ArrowLeft, ExternalLink, Mail, MapPin, Plus, Send, Trash2 } from "lucide-react";
22 import { data, Link, redirect, useLocation } from "react-router";
33
44 import { type AdminOwner, type EnterpriseInvoice, type Limit, httpStatus } from "@g1t/contracts";
157157 sent={sent}
158158 pathname={pathname}
159159 error={error("invoices")}
160+ addressError={error("address")}
160161 />
161162 <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} />
162163 <AllowancesForm
306307 sent,
307308 pathname,
308309 error,
310+ addressError,
309311 }: {
310312 email: string | null;
311313 invoices: EnterpriseInvoice[];
312314 sent: EnterpriseInvoice | null;
313315 pathname: string;
314316 error: SectionError;
317+ addressError: SectionError;
315318 }) {
316319 return (
317320 <Section id="invoices" title="Invoices" description="One Stripe invoice for every workspace it pays for, net 30, emailed by Stripe.">
334337 </Button>
335338 </form>
336339
340+ <form method="post" action={`${pathname}#invoices`} className="space-y-2 border-t border-line pt-4">
341+ <input type="hidden" name="intent" value="billing-address" />
342+ <p className="text-sm text-muted">
343+ Billing address, saved on its Stripe customer. Stripe Tax works its invoices&apos; tax out from it, so no invoice goes out without
344+ one. Leave the tax ID blank to keep the one there is.
345+ </p>
346+ {addressError && <Notice tone="error">{addressError.error}</Notice>}
347+ <div className="grid gap-2 sm:grid-cols-2">
348+ <Field label="Street">
349+ <Input name="line1" defaultValue={addressError?.values?.line1 ?? ""} autoComplete="off" />
350+ </Field>
351+ <Field label="Suite, floor">
352+ <Input name="line2" defaultValue={addressError?.values?.line2 ?? ""} autoComplete="off" />
353+ </Field>
354+ <Field label="City">
355+ <Input name="city" defaultValue={addressError?.values?.city ?? ""} autoComplete="off" />
356+ </Field>
357+ <Field label="State or region">
358+ <Input name="state" defaultValue={addressError?.values?.state ?? ""} autoComplete="off" />
359+ </Field>
360+ <Field label="Postal code" hint="Needed in the US">
361+ <Input name="postalCode" defaultValue={addressError?.values?.postalCode ?? ""} autoComplete="off" />
362+ </Field>
363+ <Field label="Country" hint="Two letters, such as US">
364+ <Input name="country" required maxLength={2} defaultValue={addressError?.values?.country ?? ""} className="uppercase" autoComplete="off" />
365+ </Field>
366+ <Field label="Tax ID kind" hint="Stripe's name, such as eu_vat or us_ein">
367+ <Input name="taxIdType" defaultValue={addressError?.values?.taxIdType ?? ""} autoComplete="off" />
368+ </Field>
369+ <Field label="Tax ID">
370+ <Input name="taxId" defaultValue={addressError?.values?.taxId ?? ""} autoComplete="off" />
371+ </Field>
372+ </div>
373+ <Button type="submit" variant="quiet">
374+ <MapPin size={14} />
375+ Save address
376+ </Button>
377+ </form>
378+
337379 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 border-t border-line pt-4 sm:flex-row sm:items-center sm:justify-between">
338380 <input type="hidden" name="intent" value="invoice" />
339381 <p className="text-sm text-muted">An invoice goes out on its own when each month closes. Send one now for what it owes so far.</p>
+56−6
77 * an `intent`.
88 */
99 import { ArrowUpRight, Bell, Bot, CreditCard, FileText, Mail, Plus, ReceiptText, Sparkles } from "lucide-react";
10−import type { ReactNode } from "react";
10+import { type ReactNode, useState } from "react";
1111 import { Form, Link } from "react-router";
1212
1313 import type { AiCredit, BillingDetails, FeatureState, Limit, UsageReport } from "@g1t/contracts";
1414
15−import { type PlanStatus, dollars, wholeDollars } from "../lib/billing";
15+import { PLUS_TAX, type PlanStatus, cardFeeCents, dollars, feeAndTax, wholeDollars } from "../lib/billing";
1616 import { money } from "../lib/usage";
1717 import { Card } from "./billing";
1818 import { ErrorText, SubmitButton } from "./ui";
102102 <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2000) / 100).toFixed(0)}</span>
103103 <span className="text-sm text-muted"> / month</span>
104104 <span className="block text-xs text-faint">with {wholeDollars(report?.included?.of ?? 10_000_000)} of usage included</span>
105+ {status.kind !== "comped" && status.kind !== "enterprise" && (
106+ <span className="block text-xs text-faint">
107+ {plan?.cardFeeCents ? `+ ${dollars(plan.cardFeeCents * 10_000)} card processing fee, ` : ""}
108+ {plan?.cardFeeCents ? PLUS_TAX : "Plus tax where it applies"}
109+ </span>
110+ )}
105111 </p>
106112 }
107113 >
203209 export function AiCreditCard({ credit, owner, enabled, staff, error }: { credit: AiCredit; owner: boolean; enabled: boolean; staff: boolean; error?: string }) {
204210 const rate = credit.agentRateMicros;
205211 const fee = credit.cardFee;
206− const feeText = fee.on ? `Stripe's card fee (${(fee.percentMicros / 10_000).toFixed(1)}% + ${dollars(fee.fixedCents * 10_000)}) is its own line at checkout.` : "No card fee.";
212+ // What is chosen, so the fee shows before Stripe's page does.
213+ const [chosenCents, setChosenCents] = useState(2_500);
214+ const chosenFee = cardFeeCents(chosenCents, fee);
215+ const stripeFee = `Stripe's ${(fee.percentMicros / 10_000).toFixed(1)}% + ${dollars(fee.fixedCents * 10_000)}`;
216+ const feeText = !fee.on
217+ ? `${feeAndTax(0)}.`
218+ : chosenCents > 0
219+ ? `On $${(chosenCents / 100).toLocaleString("en-US")}: ${feeAndTax(chosenFee).replace(/^C/, "c")} (the fee is ${stripeFee}, its own line at checkout).`
220+ : `A card processing fee (${stripeFee}) is its own line at checkout, ${PLUS_TAX}.`;
221+ const choose = (form: HTMLFormElement) => {
222+ const data = new FormData(form);
223+ const amount = String(data.get("amount") ?? "");
224+ const dollarsChosen = amount === "custom" ? Number(String(data.get("custom") ?? "").replace(/[$,\s]/g, "")) : Number(amount);
225+ setChosenCents(Number.isFinite(dollarsChosen) && dollarsChosen > 0 ? Math.round(dollarsChosen * 100) : 0);
226+ };
207227 return (
208228 <Card
209229 id="ai-credit"
235255 {!credit.freeViaDiscount && !credit.postpaid && (
236256 <>
237257 {credit.canBuy && owner && enabled ? (
238− <Form method="post" className="mt-4">
258+ <Form method="post" className="mt-4" onChange={(event) => choose(event.currentTarget)}>
239259 <input type="hidden" name="intent" value="buy-ai-credit" />
240260 <fieldset className="flex flex-wrap items-center gap-2">
241261 <legend className="mb-2 text-xs text-muted">Buy AI credit</legend>
453473 rows.push({
454474 key: "security",
455475 name: security.plan.title,
456− about: "Custom patterns, validity checks, code scanning and dependency review on private repositories.",
476+ about: `Custom patterns, validity checks, code scanning and dependency review on private repositories. ${
477+ security.plan.cardFeeCents ? `Plus a ${dollars(security.plan.cardFeeCents * 10_000)} card processing fee a month, and tax where it applies.` : "Plus tax where it applies."
478+ }`,
457479 price: `$${(security.plan.monthlyCents / 100).toFixed(security.plan.monthlyCents % 100 ? 2 : 0)} / month`,
458480 on: security.on,
459481 label: security.included ? "Included" : status === "canceling" ? "Ends at the period's end" : security.on ? "On" : "Off",
532554 ["za_vat", "South African VAT"],
533555 ];
534556
557+/** What Stripe's check of a tax ID found. */
558+const TAX_ID_STATUS: Record<string, string> = {
559+ verified: "Verified by Stripe.",
560+ pending: "Stripe is checking it.",
561+ unverified: "Stripe could not verify it. Check the number.",
562+ unavailable: "Stripe cannot check this kind of ID.",
563+};
564+
535565 const LANGUAGES: [string, string][] = [
536566 ["", "Automatic"],
537567 ["en", "English"],
550580 const a = details?.address;
551581 const disabled = !owner || !enabled;
552582 return (
553− <Card id="details" icon={<ReceiptText size={16} />} title="Invoice details" about="Who invoices are for. Kept on the workspace's Stripe customer and printed on every invoice.">
583+ <Card
584+ id="details"
585+ icon={<ReceiptText size={16} />}
586+ title="Invoice details"
587+ about="Who invoices are for. Kept on the workspace's Stripe customer and printed on every invoice. Tax is worked out from the address."
588+ >
589+ {details?.customer && !details.taxLocation && (
590+ <p className={`mt-3 rounded-lg border px-3 py-2 text-sm ${details.taxAddressNeededAt ? "border-warn/40 bg-warn/5" : "border-line bg-bg/40 text-muted"}`}>
591+ {details.taxAddressNeededAt
592+ ? "Add the billing address: Stripe needs at least the country (and in the US the ZIP code) to work out tax, so g1t is not charging the card until it is here."
593+ : "No billing address yet. Stripe needs at least the country (and in the US the ZIP code) to work out tax before g1t charges the card."}
594+ </p>
595+ )}
596+ {(details?.taxExempt === "exempt" || details?.taxExempt === "reverse") && (
597+ <p className="mt-3 text-xs text-muted">
598+ {details.taxExempt === "exempt" ? "Tax exempt: no tax is added." : "Reverse charge: you account for the tax yourself, and invoices say so."}
599+ </p>
600+ )}
554601 <Form method="post" className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
555602 <input type="hidden" name="intent" value="details" />
556603 <fieldset disabled={disabled} className="contents">
585632 </select>
586633 <input name="taxId" defaultValue={details?.taxId ?? ""} aria-label="Tax ID" className={FIELD} />
587634 </span>
635+ {details?.taxId && details.taxIdStatus && (
636+ <span className="mt-1 block text-xs text-faint">{TAX_ID_STATUS[details.taxIdStatus] ?? `Stripe's check: ${details.taxIdStatus}`}</span>
637+ )}
588638 </label>
589639 <label className="block">
590640 <span className="text-xs text-muted">Purchase order</span>
+24−1
1313 import {
1414 CAPS,
1515 CREDIT_KIND,
16+ PLUS_TAX,
1617 PREPAY,
1718 type PlanStatus,
1819 alertText,
1920 alertTone,
21+ cardFeeCents,
2022 creditLine,
2123 dollars,
2224 gigabytes,
232234 <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2000) / 100).toFixed(0)}</span>
233235 <span className="text-sm text-muted"> / month</span>
234236 <span className="block text-xs text-faint">per workspace, never per seat</span>
237+ <span className="block text-xs text-faint">
238+ {plan?.cardFeeCents ? `+ ${dollars(plan.cardFeeCents * 10_000)} card processing fee, ${PLUS_TAX}` : "Plus tax where it applies"}
239+ </span>
235240 </p>
236241 }
237242 >
644649 );
645650 }
646651
647−export function PrepayCard({ prepaidMicros, owner, live, error }: { prepaidMicros: number; owner: boolean; live: boolean; error?: string }) {
652+export function PrepayCard({
653+ prepaidMicros,
654+ owner,
655+ live,
656+ cardFee,
657+ error,
658+}: {
659+ prepaidMicros: number;
660+ owner: boolean;
661+ live: boolean;
662+ /** The card fee as billing charges it, to show it before paying. */
663+ cardFee?: { on: boolean; percentMicros: number; fixedCents: number } | null;
664+ error?: string;
665+}) {
666+ const fees = PREPAY.presets.map((amount) => `${dollars(cardFeeCents(amount * 100, cardFee) * 10_000)} on ${wholeDollars(amount * 1_000_000)}`);
648667 return (
649668 <Card
650669 id="prepay"
685704 By bank transfer (from {wholeDollars(PREPAY.bankFrom * 1_000_000)}; counted when it arrives)
686705 </label>
687706 </RadioGroup>
707+ <p className="text-xs text-faint">
708+ {cardFee?.on ? `By card, a card processing fee is its own line (${fees.join(", ")}); a bank transfer has none. ` : ""}
709+ Tax is added where it applies, from your billing address; what you prepay is credited in full.
710+ </p>
688711 {!live && <p className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>}
689712 <ErrorText>{error}</ErrorText>
690713 </Form>
+47−0
1+/**
2+ * What a free workspace may not do, and the way forward: a free workspace
3+ * adds no one (members, invites, outside collaborators) until it starts the
4+ * plan, and a person owns at most one free workspace. Identity refuses both
5+ * either way; these say so before anyone tries.
6+ */
7+import { Sparkles } from "lucide-react";
8+
9+import { ButtonLink } from "./ui";
10+
11+/** Where a workspace's plan is started. */
12+export function planHref(workspace: string): string {
13+ return `/${workspace}/-/billing#plan`;
14+}
15+
16+/** "Start the plan to invite people", with the button, for a free workspace's owners. */
17+export function StartPlanToInvite({
18+ workspace,
19+ owner,
20+ outside = false,
21+}: {
22+ workspace: string;
23+ owner: boolean;
24+ /** On a repository's Access page: members can still be given roles. */
25+ outside?: boolean;
26+}) {
27+ return (
28+ <div role="note" className="rounded-xl border border-accent/30 bg-accent/5 p-4">
29+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
30+ <Sparkles size={16} className="hidden shrink-0 text-accent sm:block" aria-hidden />
31+ <div className="min-w-0 grow">
32+ <p className="text-sm font-medium">Start the plan to invite people</p>
33+ <p className="mt-1 text-sm text-muted">
34+ {workspace} is a free workspace, so it cannot add people
35+ {outside ? " from outside it. Its members can still be given a role here." : ". Its members stay as they are."}{" "}
36+ {owner ? "The plan is for everyone in the workspace at one price, never per person." : "An owner can start it."}
37+ </p>
38+ </div>
39+ {owner && (
40+ <span className="shrink-0">
41+ <ButtonLink to={planHref(workspace)}>Start the plan</ButtonLink>
42+ </span>
43+ )}
44+ </div>
45+ </div>
46+ );
47+}
+19−2
141141 </dl>
142142 )}
143143
144+ {((totals.taxMicros ?? 0) !== 0 || (totals.cardFeeMicros ?? 0) !== 0) && (
145+ <p className="mt-2 text-xs text-faint">
146+ Paid with this month's payments on top of what they credited: tax {dollars(totals.taxMicros ?? 0)}, card processing fees{" "}
147+ {dollars(totals.cardFeeMicros ?? 0)}. Prices exclude tax; neither comes from your balance.
148+ </p>
149+ )}
144150 {((totals.covered?.length ?? 0) > 0 || (totals.carriedMicros ?? 0) > 0) && (
145151 <ul className="mt-2 space-y-1 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
146152 {totals.covered?.map((paid) => (
174180 <span className="font-mono tabular-nums text-faint">{charge(g.chargedMicros)}</span>
175181 </div>
176182 <ul className="divide-y divide-line">
177− {g.lines.map((line) => (
183+ {g.lines.map((line) =>
184+ line.kind === "Tax" || line.kind === "Card processing fees" ? (
185+ // Paid with the day's payments, on top of what reached the balance: never charged.
186+ <li key={line.kind} className="flex items-center gap-3 px-4 py-3 text-sm">
187+ <span className="w-3.5 shrink-0" />
188+ <span className="grow truncate text-muted">
189+ {line.kind} <span className="text-xs text-faint">· paid with the payment, not from the balance</span>
190+ </span>
191+ <span className="w-24 shrink-0 text-right font-mono tabular-nums text-muted">{dollars(line.passedMicros ?? 0)}</span>
192+ </li>
193+ ) : (
178194 <StatementLineRow
179195 key={line.kind}
180196 slug={slug}
186202 day={group === "day" ? g.key : null}
187203 project={group === "project" ? g.key : null}
188204 />
189− ))}
205+ ),
206+ )}
190207 {(g.discountMicros ?? 0) > 0 && (
191208 <li className="flex items-center gap-3 px-4 py-3 text-sm">
192209 <span className="w-3.5 shrink-0" />
+20−0
88 alertText,
99 alertTone,
1010 cardCheckResult,
11+ cardFeeCents,
12+ feeAndTax,
1113 creditLine,
1214 dollars,
1315 needsAttention,
3537 assert.equal(creditLine({ ...grant, state: "revoked", leftMicros: 0 }, now), "$25.00 credit, withdrawn");
3638 });
3739
40+test("the card fee shown before paying is the one billing charges, and prices exclude tax", () => {
41+ const fee = { on: true, percentMicros: 29_000, fixedCents: 30 };
42+ // The same figures as billing's own tests (ai.rs, tax.rs).
43+ assert.equal(cardFeeCents(2_500, fee), 106);
44+ assert.equal(cardFeeCents(2_000, fee), 91);
45+ assert.equal(cardFeeCents(2_000, { ...fee, on: false }), 0);
46+ assert.equal(cardFeeCents(0, fee), 0);
47+ assert.equal(cardFeeCents(2_000, null), 0);
48+ assert.equal(feeAndTax(59), "Card processing fee $0.59, plus tax where it applies");
49+ assert.equal(feeAndTax(0), "Plus tax where it applies");
50+});
51+
3852 test("money reads as dollars", () => {
3953 assert.equal(dollars(9_500_000), "$9.50");
4054 assert.equal(dollars(-1_250_000), "−$1.25");
305319 assert.equal(parsed.value.address.country, "DE");
306320 form.set("taxId", "");
307321 assert.equal(parseInvoiceDetails(form).ok, false);
322+ // Tax is worked out from the address: in the US, the ZIP code is needed.
323+ form.set("taxIdType", "");
324+ form.set("country", "us");
325+ assert.equal(parseInvoiceDetails(form).ok, false);
326+ form.set("postalCode", "94107");
327+ assert.ok(parseInvoiceDetails(form).ok);
308328 });
+23−0
2323 return `${sign}$${abs.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits })}`;
2424 }
2525
26+/** Prices on g1t exclude tax; Stripe adds it at checkout from the billing address. */
27+export const PLUS_TAX = "plus tax where it applies";
28+
29+/**
30+ * The card processing fee on a card payment of `cents`, as billing works it
31+ * out (`ai::card_fee_cents`): Stripe's percent and fixed fee grossed up, so
32+ * what is left after Stripe's fee is the amount, rounded up to the cent.
33+ * 0 when the fee is off.
34+ */
35+export function cardFeeCents(cents: number, fee: { on: boolean; percentMicros: number; fixedCents: number } | null | undefined): number {
36+ if (!fee?.on || !(cents > 0)) return 0;
37+ const rate = fee.percentMicros / MICROS_PER_DOLLAR;
38+ if (!(rate >= 0 && rate < 0.5)) return 0;
39+ return Math.max(0, Math.ceil((cents + fee.fixedCents) / (1 - rate)) - cents);
40+}
41+
42+/** "Card processing fee $1.06, plus tax where it applies", as shown before paying. */
43+export function feeAndTax(feeCents: number): string {
44+ return feeCents > 0 ? `Card processing fee ${dollars(feeCents * 10_000)}, ${PLUS_TAX}` : `Plus tax where it applies`;
45+}
46+
2647 /** A form's dollar amount as micros, or null when it is empty or not a number. */
2748 export function readDollars(value: FormDataEntryValue | null | undefined): number | null {
2849 const text = String(value ?? "").replace(/[$,\s]/g, "");
428449 if (email && !/^[^\s@]+@[^\s@]+$/.test(email)) return { ok: false, error: "That is not an email address." };
429450 const country = text("country").toUpperCase();
430451 if (country && !/^[A-Z]{2}$/.test(country)) return { ok: false, error: "The country is two letters, such as US or DE." };
452+ // Stripe Tax places a US customer by ZIP code: without it, tax cannot be worked out.
453+ if (country === "US" && !text("postalCode")) return { ok: false, error: "Add the ZIP code: in the US, tax is worked out from it." };
431454 const taxIdType = text("taxIdType");
432455 const taxId = text("taxId");
433456 if (Boolean(taxIdType) !== Boolean(taxId)) return { ok: false, error: "Give the tax ID's kind and its number together." };
+27−4
9292 },
9393 {
9494 title: "No seats, ever",
95− body: "Add as many people and agents as you like. A workspace pays one flat price for the plan, and for what it uses past what the plan includes.",
95+ body: "Once a workspace is on the plan, add as many people and agents as you like. It pays one flat price for the plan, and for what it uses past what the plan includes.",
96+ },
97+ {
98+ title: "Prices exclude tax",
99+ body: "Every price here is before tax. Stripe adds sales tax, VAT or GST where it applies, worked out from the billing address, and shows it as its own line before you pay and on every receipt and invoice. A valid business tax ID is applied where the law says so.",
100+ },
101+ {
102+ title: "Card fees are passed on, nothing more",
103+ body: "Paying by card adds Stripe's card processing fee as its own line, shown before you pay, so the 20% is never spent on fees. A bank transfer or invoiced billing has no card fee.",
96104 },
97105 ];
98106
157165 note: "Custom patterns, validity checks, code scanning, dependency review and the security overview on private repositories are the Security and quality activation. On public repositories they are free.",
158166 },
159167 { what: "Single sign-on", free: "On every plan, once it is built", plan: "On every plan, once it is built" },
160− { what: "Members", free: "Unlimited", plan: "Unlimited" },
161168 {
169+ what: "Members",
170+ free: "Only the people already in it: a free workspace cannot add members, invite people or invite outside collaborators",
171+ plan: "Unlimited, never per seat",
172+ note: "Each person can own one free workspace. More workspaces need the plan.",
173+ },
174+ {
162175 what: "Usage past what is included",
163176 free: "Not possible: a free workspace never runs up a bill",
164177 plan: "Charged at cost plus 20%, up to a spend limit you set. No quotas: only your limit stops anything.",
182195 <h1 className="mt-2 text-3xl font-semibold tracking-tight sm:text-4xl">One plan, and usage at cost plus 20%</h1>
183196 <p className="mt-3 max-w-2xl text-muted">
184197 The forge is free for everyone. Work that runs on g1t's machines is metered at what it costs g1t, plus 20%. The
185− numbers on this page are the live price book g1t charges from.
198+ numbers on this page are the live price book g1t charges from. Prices exclude tax, which is added where it applies.
186199 </p>
187200 {free && (
188201 <div className="mt-5 rounded-xl border border-accent/30 bg-accent/5 px-4 py-3 text-sm">
197210 <p>
198211 <span className="text-3xl font-semibold tabular-nums">${price}</span>{" "}
199212 <span className="text-sm text-muted">a month per workspace</span>
213+ <span className="block text-right text-xs text-faint">
214+ {plan.cardFeeCents ? `+ $${(plan.cardFeeCents / 100).toFixed(2)} card processing fee, plus tax where it applies` : "Plus tax where it applies"}
215+ </span>
200216 </p>
201217 </div>
202218 <ul className="mt-4 space-y-1.5 text-sm text-muted">
237253 <p>
238254 <span className="text-3xl font-semibold tabular-nums">${(securityPlan.monthlyCents / 100).toFixed(securityPlan.monthlyCents % 100 ? 2 : 0)}</span>{" "}
239255 <span className="text-sm text-muted">a month per workspace</span>
256+ <span className="block text-right text-xs text-faint">
257+ {securityPlan.cardFeeCents
258+ ? `+ $${(securityPlan.cardFeeCents / 100).toFixed(2)} card processing fee, plus tax where it applies`
259+ : "Plus tax where it applies"}
260+ </span>
240261 </p>
241262 </div>
242263 <p className="mt-2 text-sm text-muted">
490511 <tr>
491512 <td className="px-4 py-3">
492513 <p className="font-medium">Card processing fee</p>
493− <p className="text-xs text-faint">On AI credit bought by card, as its own line at checkout; never on invoices</p>
514+ <p className="text-xs text-faint">
515+ On every card payment, as its own line shown before you pay; never on bank transfers or invoiced billing
516+ </p>
494517 </td>
495518 <td className="px-4 py-3 text-muted">Stripe's fee</td>
496519 <td className="hidden px-4 py-3 tabular-nums sm:table-cell">—</td>
+9−3
1717 import type { Route } from "./+types/settings-access";
1818 import { RoleSelect, RolesTable } from "../../components/access";
1919 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
20+import { StartPlanToInvite } from "../../components/start-plan";
2021 import { SettingsSection as Section } from "../../components/settings-section";
2122 import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../../components/ui";
2223 import { Badge } from "../../components/ui/badge";
3233 } from "../../components/ui/alert-dialog";
3334 import { page } from "../../lib/meta";
3435 import { refusal, requireInsider } from "../../lib/access.server";
35−import { identity } from "../../lib/services.server";
36+import { billing, identity } from "../../lib/services.server";
3637 import { assertSameOrigin, requireUser, roleIn, unwrap } from "../../lib/session.server";
3738
3839 export function meta({ params, ...args }: Route.MetaArgs) {
4647 const manage = access.can.manage_access;
4748 // The workspace's teams, for Add team: only Admins add one, and a
4849 // failure there leaves the rest of the page as it is.
49− const [found, teams] = await Promise.all([
50+ const [found, teams, free] = await Promise.all([
5051 identity.repoAccess(params.owner, params.repo, viewer),
5152 manage ? identity.listTeams(viewer, params.owner).catch(() => null) : Promise.resolve(null),
53+ // A free workspace invites no one from outside; identity refuses it
54+ // either way, so a failure here only hides the note.
55+ manage ? billing.freeWorkspaces([params.owner]).catch(() => [] as string[]) : Promise.resolve([] as string[]),
5256 ]);
5357 return {
5458 access: unwrap(found),
5559 teams: teams?.ok ? teams.value : ([] as Team[]),
5660 manage,
61+ free: free.includes(params.owner.toLowerCase()),
5762 // Owners change the base permission, on the workspace's People page.
5863 owner: roleIn(viewer, params.owner) === "owner",
5964 };
143148 };
144149
145150 export default function RepoAccessSettings({ loaderData, actionData, params }: Route.ComponentProps) {
146− const { access, manage, owner, teams } = loaderData;
151+ const { access, manage, owner, teams, free } = loaderData;
147152 const base = `/${params.owner}/${params.repo}`;
148153 const full = `${params.owner}/${params.repo}`;
149154 const pending = access.invitations.filter((invitation) => invitation.status === "pending");
195200 title="Add people"
196201 about="A member of the workspace gets the role at once. Anyone else gets an invitation, by email, and has the role once they accept it."
197202 >
203+ {free && <StartPlanToInvite workspace={params.owner} owner={owner} outside />}
198204 <AddForm result={result} />
199205 </Section>
200206 )}
+48−3
316316 {notice && <p className="mb-6 rounded-lg border border-accent/30 bg-accent/5 px-4 py-2.5 text-sm">{notice}</p>}
317317 {problem && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/5 px-4 py-2.5 text-sm">{problem}</p>}
318318
319+ <Suspense fallback={null}>
320+ <Await resolve={details}>
321+ {(loaded) =>
322+ loaded?.taxAddressNeededAt ? (
323+ <div role="alert" className="mb-6 rounded-xl border border-warn/40 bg-warn/5 p-4 text-sm">
324+ <p className="font-medium">Add a billing address</p>
325+ <p className="mt-1 text-muted">
326+ Stripe needs it to work out tax, so g1t did not charge the card. Nothing is lost: the charge goes through once the
327+ address is saved.{" "}
328+ {owner ? (
329+ <a href="#details" className="text-fg underline underline-offset-2">
330+ Add it under Invoice details
331+ </a>
332+ ) : (
333+ "An owner adds it under Invoice details."
334+ )}
335+ </p>
336+ </div>
337+ ) : null
338+ }
339+ </Await>
340+ </Suspense>
319341 {entitlements && <SpikeBanner slug={slug} entitlements={entitlements} owner={owner} />}
320342 {err("spike") && <p className="mb-6 text-sm text-danger">{err("spike")}</p>}
321343 <Alerts alerts={entitlements?.alerts ?? []} />
335357 <SpendLimitCard limit={limit} owner={owner} error={err("limit")} />
336358 <BudgetAlerts limit={limit} owner={owner} error={err("budget")} />
337359 <RaiseCard requests={requests} owner={owner} error={err("raise")} />
338− <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live || !staff} error={err("prepay")} />
360+ <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live || !staff} cardFee={ai?.cardFee ?? null} error={err("prepay")} />
339361 </>
340362 )}
341363
395417 Each month closes with an itemised invoice. No card is charged less than{" "}
396418 {dollars(entitlements?.minChargeMicros ?? 5 * MICROS_PER_DOLLAR, 0)}; less carries over.
397419 </li>
398− <li>No seats: add as many people and agents as you like.</li>
420+ <li>
421+ Prices exclude tax. Stripe adds tax where it applies, worked out from the billing address under Invoice details, and it
422+ is its own line on every receipt and invoice.
423+ </li>
424+ <li>
425+ Card payments carry Stripe's card processing fee as their own line, shown before you pay. Bank transfers and invoiced
426+ billing have none.
427+ </li>
428+ <li>No seats: add as many people and agents as you like, once the workspace is on the plan.</li>
399429 </ul>
400430 <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm">
401431 <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg">
419449 return (
420450 <section className="mb-10">
421451 <h2 className="font-medium">Usage invoices</h2>
422− <p className="mt-1 text-sm text-muted">One when each month closes, and one each time g1t charges the card near your limit.</p>
452+ <p className="mt-1 text-sm text-muted">
453+ One when each month closes, and one each time g1t charges the card near your limit. Amounts are for usage; the card fee
454+ and tax are their own lines.
455+ </p>
423456 <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line">
424457 {invoices.map((invoice) => (
425458 <li key={invoice.invoiceId} className="px-4 py-3 text-sm">
453486 <span className="font-mono tabular-nums">{dollars(line.amountMicros)}</span>
454487 </li>
455488 ))}
489+ {(invoice.feeMicros ?? 0) > 0 && (
490+ <li className="flex justify-between gap-4">
491+ <span>Card processing fee</span>
492+ <span className="font-mono tabular-nums">{dollars(invoice.feeMicros ?? 0)}</span>
493+ </li>
494+ )}
495+ {(invoice.taxMicros ?? 0) > 0 && (
496+ <li className="flex justify-between gap-4">
497+ <span>Tax</span>
498+ <span className="font-mono tabular-nums">{dollars(invoice.taxMicros ?? 0)}</span>
499+ </li>
500+ )}
456501 </ul>
457502 </li>
458503 ))}
+90−33
1−import { Form, redirect } from "react-router";
1+import { Form, Link, redirect } from "react-router";
22
33 import type { Route } from "./+types/new";
44 import { page } from "../../lib/meta";
5−import { ErrorText, Field, Input, SubmitButton } from "../../components/ui";
6−import { identity } from "../../lib/services.server";
5+import { planHref } from "../../components/start-plan";
6+import { ButtonLink, ErrorText, Field, Input, SubmitButton } from "../../components/ui";
7+import { billing, identity } from "../../lib/services.server";
78 import { assertSameOrigin, nextPath, requireUser } from "../../lib/session.server";
89
910 export function meta(args: Route.MetaArgs) {
1011 return page(args, { title: "New workspace · g1t" });
1112 }
1213
13−export function loader({ request, context }: Route.LoaderArgs) {
14+export async function loader({ request, context }: Route.LoaderArgs) {
1415 const user = requireUser(context, request);
15− return { user, first: (user.workspaces ?? []).length === 0 };
16+ const owned = (user.workspaces ?? []).filter((membership) => membership.role === "owner").map((membership) => membership.slug);
17+ // A person owns at most one free workspace; identity refuses a second
18+ // either way, so a failure here only leaves the form up.
19+ const free = owned.length > 0 ? await billing.freeWorkspaces(owned).catch(() => [] as string[]) : [];
20+ return { user, first: (user.workspaces ?? []).length === 0, free };
1621 }
1722
1823 export async function action({ request, context }: Route.ActionArgs) {
3439 loaderData,
3540 actionData,
3641 }: Route.ComponentProps) {
37− const { user, first } = loaderData;
42+ const { user, first, free } = loaderData;
3843 return (
3944 <main className="mx-auto max-w-lg px-4 py-12">
4045 <h1 className="text-xl font-semibold">
4752 address: <span className="font-mono text-fg">g1t.sh/workspace/repo</span>.
4853 Use one for yourself, and one for each team or company you work with.
4954 </p>
50− {!user.verified && (
51− <p className="mt-4 rounded-md border border-warn/30 bg-warn/10 px-3 py-2 text-sm">
52− Confirm your email address first. We sent you a link.
53− </p>
55+ {free.length > 0 ? (
56+ <OneFreeWorkspace free={free} />
57+ ) : (
58+ <>
59+ {!user.verified && (
60+ <p className="mt-4 rounded-md border border-warn/30 bg-warn/10 px-3 py-2 text-sm">
61+ Confirm your email address first. We sent you a link.
62+ </p>
63+ )}
64+ <Form method="post" className="mt-8 space-y-4">
65+ <Field
66+ label="Name in URLs"
67+ hint="Lowercase letters, digits and single hyphens. An owner can change it later; old addresses redirect for 90 days."
68+ >
69+ <div className="flex items-center gap-2 font-mono text-sm">
70+ <span className="text-muted">g1t.sh/</span>
71+ <Input
72+ name="slug"
73+ required
74+ autoFocus
75+ maxLength={39}
76+ defaultValue={first ? user.username : ""}
77+ pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*"
78+ />
79+ </div>
80+ </Field>
81+ <Field label="Display name (optional)">
82+ <Input name="displayName" maxLength={80} />
83+ </Field>
84+ <p className="text-xs text-faint">
85+ A new workspace is free. Each person can own one free workspace; more need the plan on the ones you have.
86+ </p>
87+ <ErrorText>{actionData?.error}</ErrorText>
88+ <SubmitButton pending="Creating…">Create workspace</SubmitButton>
89+ </Form>
90+ </>
5491 )}
55− <Form method="post" className="mt-8 space-y-4">
56− <Field
57− label="Name in URLs"
58− hint="Lowercase letters, digits and single hyphens. An owner can change it later; old addresses redirect for 90 days."
59− >
60− <div className="flex items-center gap-2 font-mono text-sm">
61− <span className="text-muted">g1t.sh/</span>
62− <Input
63− name="slug"
64− required
65− autoFocus
66− maxLength={39}
67− defaultValue={first ? user.username : ""}
68− pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*"
69− />
70− </div>
71− </Field>
72− <Field label="Display name (optional)">
73− <Input name="displayName" maxLength={80} />
74− </Field>
75− <ErrorText>{actionData?.error}</ErrorText>
76− <SubmitButton pending="Creating…">Create workspace</SubmitButton>
77− </Form>
7892 </main>
7993 );
8094 }
95+
96+/** Why there is no form: the person owns a free workspace already. */
97+function OneFreeWorkspace({ free }: { free: string[] }) {
98+ const [first] = free;
99+ return (
100+ <section className="mt-8 rounded-xl border border-line bg-surface p-5">
101+ <h2 className="font-medium">You already own a free workspace</h2>
102+ <p className="mt-2 text-sm text-muted">
103+ Each person can own one workspace that is not on the g1t plan.{" "}
104+ {free.length === 1 ? (
105+ <>
106+ Yours is <span className="font-mono text-fg">{first}</span>.
107+ </>
108+ ) : (
109+ <>
110+ You own {free.length} from before (
111+ {free.map((slug, i) => (
112+ <span key={slug}>
113+ {i > 0 && ", "}
114+ <span className="font-mono text-fg">{slug}</span>
115+ </span>
116+ ))}
117+ ), and keep them all.
118+ </>
119+ )}{" "}
120+ A new workspace starts free, so to make one, start the plan on {free.length === 1 ? "it" : "each of them"}, or delete{" "}
121+ {free.length === 1 ? "it" : "the ones"} you no longer use.
122+ </p>
123+ <div className="mt-4 flex flex-wrap items-center gap-3">
124+ <ButtonLink to={planHref(first)}>Start the plan on {first}</ButtonLink>
125+ <Link to={`/${first}/-/settings`} className="text-sm text-muted hover:text-fg">
126+ Workspace settings
127+ </Link>
128+ </div>
129+ <p className="mt-4 text-xs text-faint">
130+ The plan is one price for everyone in the workspace, never per person.{" "}
131+ <Link to="/pricing" className="underline underline-offset-2 hover:text-fg">
132+ Pricing
133+ </Link>
134+ </p>
135+ </section>
136+ );
137+}
+31−11
1717 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
1818 import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
1919 import { inviteLink, inviteState, moreInvitesMailto } from "../../lib/invites";
20−import { identity } from "../../lib/services.server";
20+import { billing, identity } from "../../lib/services.server";
21+import { StartPlanToInvite } from "../../components/start-plan";
2122 import {
2223 assertSameOrigin,
2324 getViewer,
3738 // someone a repository is shared with, gets nothing here.
3839 if (!role) throw data(null, { status: 404 });
3940 const owner = role === "owner";
40− const [members, invites, workspace, outside, teams] = await Promise.all([
41+ const [members, invites, workspace, outside, teams, free] = await Promise.all([
4142 identity.listMembers(params.owner, viewer),
4243 owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null,
4344 identity.getWorkspace(params.owner),
4445 owner ? identity.outsideCollaborators(viewer, params.owner).catch(() => null) : null,
4546 // Each member's teams, as the viewer may see them.
4647 identity.teamMemberships(viewer, params.owner).catch(() => null),
48+ // A free workspace adds no one until it starts the plan; identity
49+ // refuses it either way, so a failure here only hides the note.
50+ billing.freeWorkspaces([params.owner]).catch(() => [] as string[]),
4751 ]);
4852 return {
4953 role,
54+ free: free.includes(params.owner.toLowerCase()),
5055 members: unwrap(members),
5156 invites: invites?.ok ? invites.value : [],
5257 base: workspace?.basePermission ?? DEFAULT_BASE_PERMISSION,
9398 };
9499
95100 export default function WorkspacePeople({ loaderData, actionData, params }: Route.ComponentProps) {
96− const { role, members, invites, origin, base, outside, teams } = loaderData;
101+ const { role, members, invites, origin, base, outside, teams, free } = loaderData;
97102 const owner = role === "owner";
98103 const pending = invites.filter((invite) => invite.status === "pending");
99104 const [search, setSearch] = useSearchParams();
144149 </li>
145150 ))}
146151 </ul>
147− {owner && (
152+ {owner && free && (
153+ <div className="mt-6">
154+ <StartPlanToInvite workspace={params.owner} owner={owner} />
155+ </div>
156+ )}
157+ {owner && !free && (
148158 // Empty again once the person is on the list; kept as typed when it failed.
149159 <Form
150160 method="post"
248258 <OutsideCollaborators
249259 people={outside}
250260 slug={params.owner}
261+ free={free}
251262 error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null}
252263 />
253264 </TabsContent>
322333 function OutsideCollaborators({
323334 people,
324335 slug,
336+ free,
325337 error,
326338 }: {
327339 people: Route.ComponentProps["loaderData"]["outside"];
328340 slug: string;
341+ free: boolean;
329342 error: string | null | undefined;
330343 }) {
331344 return (
334347 People given a role on one or more of {slug}'s repositories without being members. They see only those
335348 repositories. Change or take away a role on the repository's Access settings.
336349 </p>
350+ {free && (
351+ <div className="mb-4">
352+ <StartPlanToInvite workspace={slug} owner />
353+ </div>
354+ )}
337355 {people.length === 0 ? (
338356 <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center">
339357 <Users size={18} className="mx-auto text-faint" />
352370 </Link>
353371 {person.name && <span className="ml-2 text-sm text-muted">{person.name}</span>}
354372 </div>
355− <Form method="post">
356− <input type="hidden" name="action" value="convert" />
357− <input type="hidden" name="member" value={person.username} />
358− <SubmitButton variant="quiet" match={{ action: "convert", member: person.username }} pending="Converting…">
359− Convert to member
360− </SubmitButton>
361− </Form>
373+ {!free && (
374+ <Form method="post">
375+ <input type="hidden" name="action" value="convert" />
376+ <input type="hidden" name="member" value={person.username} />
377+ <SubmitButton variant="quiet" match={{ action: "convert", member: person.username }} pending="Converting…">
378+ Convert to member
379+ </SubmitButton>
380+ </Form>
381+ )}
362382 </div>
363383 <ul className="mt-2 flex flex-wrap gap-1.5 pl-10">
364384 {person.repos.map((grant) => (
+9−1
1414 const month = new URL(request.url).searchParams.get("month");
1515 const statement = await billing.statement(params.owner, viewer, month, "day");
1616 if (!statement.ok) throw data(null, { status: 404 });
17− const kinds = [...new Set(statement.value.groups.flatMap((group) => group.lines.map((line) => line.kind)))];
17+ // Tax and card fees are paid with payments, not ledger entries: a row a day each.
18+ const passed = new Set(["Tax", "Card processing fees"]);
19+ const kinds = [...new Set(statement.value.groups.flatMap((group) => group.lines.map((line) => line.kind)))].filter((kind) => !passed.has(kind));
1820 const rows: string[][] = [
1921 [
2022 "date",
4749 if (entries.value.length < 50) break;
4850 }
4951 }
52+ for (const group of statement.value.groups) {
53+ for (const line of group.lines.filter((l) => passed.has(l.kind))) {
54+ const amount = ((line.passedMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6);
55+ rows.push([group.key, line.kind, "Paid with the day's payments, not from the balance", "", "", "", "", "", "", amount, "", "", "", ""]);
56+ }
57+ }
5058 const body = [rows[0], ...rows.slice(1).sort((a, b) => a[0].localeCompare(b[0]))].map((r) => r.map(cell).join(",")).join("\r\n");
5159 return new Response(`${body}\r\n`, {
5260 headers: {
+130−1
117117 what the plan's included usage, a trial, the open-source pool or g1t paid.
118118 g1t's own (comped) workspaces are valued at cost plus the margin.
119119 - **Cash** = what workspaces paid: `-amount_micros`, and the plan's price.
120+ Never tax or card fees: a payment credits the balance, and
121+ `plan_payments`, without them (see [Tax and the card fee](#tax-and-the-card-fee)).
120122 - **Given away** = the part of the cost that went on usage g1t paid for
121123 itself on purpose, by why:
122124 - **comped**: all of a comped workspace's cost, every bucket;
499501 line `<run>/agent` (later `<run>/agent/<tokens>`), with `quantity` the
500502 tokens. Runs on a workspace's own provider have no session here and are not
501503 charged the rate. **Card fee switch:** sudo → Costs → Guardrails → *Card fee
502−on AI credit bought by card* (`cost_settings.card_fee`, `on`/`off`).
504+on card payments* (`cost_settings.card_fee`, `on`/`off`, on by default). It
505+covers every card payment now, not only AI credit: see
506+[Tax and the card fee](#tax-and-the-card-fee).
503507
504508 ### Budgets
505509
711715 came back from; renaming a workspace (the customer's name). Nothing a page
712716 view reads.
713717
718+## Tax and the card fee
719+
720+Owner decision 2026-10-08. Code: `services/billing/src/tax.rs` (what is
721+kept, the address hold), `stripe.rs` (every request's tax fields),
722+`invoices.rs`, `webhooks.rs`, `ai.rs`; migration
723+`0041_tax_and_card_fees.sql`.
724+
725+### What Stripe is asked
726+
727+Every price excludes tax (`tax_behavior=exclusive`) and carries tax code
728+`txcd_10103001` (software as a service, business use; the card fee too,
729+since a fee for paying for a sale follows the sale). Fields are valid for
730+`2025-02-24.acacia`.
731+
732+| Request | Tax | Card fee |
733+| --- | --- | --- |
734+| Checkout, plan or Security (`subscription_fields`) | `automatic_tax[enabled]`, `billing_address_collection=required`, `tax_id_collection[enabled]`, with a customer `customer_update[address]=auto` and `[name]=auto`; the subscription keeps automatic tax for every renewal | A second recurring line, *Card processing fee* |
735+| Subscription on a saved card (`saved_subscription_fields`) | `automatic_tax[enabled]`; a customer Stripe Tax cannot place fails, and the person is sent to Checkout, which asks for the address | `items[1]`, the `card_fee` product |
736+| Checkout, prepay (`prepay_fields`) | As above | By card only; none by bank transfer |
737+| Checkout, AI credit (`credit_fields`) | As above | Its own line |
738+| Checkout, card check (`card_check_fields`) | Setup mode: nothing charged, nothing taxed. `billing_address_collection=required`, and the card's address is copied onto a customer with none (`fill_address`) | — |
739+| Auto-reload (`charge_saved`) | `POST /tax/calculations` first (credit and fee as lines), the PaymentIntent for the total, then `POST /tax/transactions/create_from_calculation` with the PaymentIntent as reference; a refund reverses its share (`create_reversal`, `mode=partial`) | In the calculation and the amount |
740+| Workspace invoice, month close and threshold (`invoice_workspace`) | `automatic_tax[enabled]` on the draft; each item `tax_behavior`, `tax_code` | An item *Card processing fee*, when the default payment method is a card |
741+| Enterprise invoice (`invoice_enterprise`) | The same | Never |
742+| Products (`Stripe::product`) | Made with `tax_code`; one found without it is given it | The `card_fee` product, `metadata[g1t]=card_fee` |
743+
744+### What is kept
745+
746+A payment credits the balance with what it paid for, never its tax or fee:
747+prepay credits the page's `amount_subtotal` less the fee line
748+(`credit_prepayment`), a workspace invoice `amount_paid − tax − fee`
749+(`credit_invoice`), AI credit its credit amount, and `plan_payments` the
750+plan's invoice less its tax and *Card processing fee* lines
751+(`stripe::invoice_split`). Each payment's tax and fee are rows in
752+`tax_and_fees` (`<reference>/tax`, `<reference>/card_fee`; the enterprise's
753+account id in `workspace` for its invoices), with the PaymentIntent, so a
754+refund (`charge.refunded`) gives back the balance, tax and fee in
755+proportion (`tax::refund_split`, negative rows under `refund/<charge>/…`).
756+`workspace_invoices.fee_micros` and `tax_micros` sit beside each usage
757+invoice.
758+
759+- **Statement.** *Tax* and *Card processing fees* are their own lines per
760+ day (`StatementLine.passed_micros`), never in `charged_micros`; totals
761+ `tax_micros`, `card_fee_micros`. The CSV has a row a day for each.
762+- **Margin.** Cash never holds them, so margin is untouched. sudo → Costs
763+ shows **Tax collected** and **Card fees passed on** for the range
764+ (`OverallMargin.tax_collected_micros`, `card_fees_micros`). Tax is owed to
765+ the authorities: file it from Stripe Tax's reports, never from g1t's.
766+
767+### No address
768+
769+Stripe Tax needs a country (in the US a ZIP code, in Canada a postal code
770+or province: `stripe::address_places_customer`). Before a workspace
771+invoice is drafted, g1t checks the customer; without an address, or when
772+Stripe leaves the draft at `requires_location_inputs` or refuses with
773+`customer_tax_location_invalid`, nothing is charged:
774+`accounts.tax_address_needed_at` is set, the owners are emailed once
775+(`notify_owners`), and Billing shows **Add a billing address**. Saving
776+Invoice details with an address Stripe Tax can use clears it, as does a
777+charge that goes through. Work is not stopped for it; the limits still
778+apply. Auto-reload without an address fails like a declined card (turned
779+off, owners told). An enterprise's invoice is not sent without an address:
780+sudo → the enterprise → Invoices → **Billing address** (`admin_enterprise_address`,
781+with its tax ID; audited `billing_address`).
782+
783+### The card fee
784+
785+`card_fee_cents` grosses Stripe's fee up so the amount paid for is left
786+after it: `(amount + 30¢) / (1 − 2.9%)`, rounded up; $0.91 on $20, $1.06
787+on $25. It is worked out on the amount before tax, so Stripe's fee on the
788+tax itself (a few cents) is g1t's. It is shown before paying: the plan card
789+and pricing page (`Plan.card_fee_cents`), AI credit (*Card processing fee
790+$1.06, plus tax where it applies*), Prepay. Never on a bank transfer or an
791+enterprise's (`send_invoice`) invoice. Meters stay at cost + 20% and models
792+at the provider's price plus the agent rate (price versions in migration
793+0040); the fee is passed through, not margin.
794+
795+### Tax-exempt customers and tax IDs
796+
797+g1t never sets `tax_exempt`. For a customer who sends an exemption
798+certificate, set it in Stripe's dashboard (Customers → the customer → Tax
799+status: Exempt, or Reverse charge); Billing then says so. Tax IDs come
800+from Checkout (`tax_id_collection`) or Invoice details (`set_billing_details`,
801+validated against Stripe's types in `details::TAX_ID_TYPES`); Stripe checks
802+EU VAT numbers and Stripe Tax applies a reverse charge where it should.
803+Billing shows Stripe's `verification.status`.
804+
805+### In Stripe's dashboard (not done by g1t)
806+
807+1. **Settings → Tax → Get started**: turn on Stripe Tax in live and test
808+ mode.
809+2. **Origin address**: Flagon, Inc.'s head office address.
810+3. **Default tax code**: Software as a service, business use
811+ (`txcd_10103001`); **default tax behavior**: exclusive.
812+4. **Registrations**: add each jurisdiction where Flagon is registered to
813+ collect (its home state at least; then states as thresholds are
814+ crossed, which Stripe Tax's monitoring flags; the EU's OSS and the UK
815+ if selling there). Stripe collects only where a registration exists.
816+5. **Customer portal**: tax ID and address updates are already allowed
817+ (`portal_configuration`).
818+6. Refund an invoice through a **credit note**, so its tax is reversed in
819+ Stripe Tax.
820+
821+## Free workspaces
822+
823+Owner decision 2026-10-08: one free workspace per person, and a free
824+workspace adds no one. Billing answers one question, `free_workspaces`
825+(`credits.rs`): which of the given workspaces are on no paid plan
826+(`plan_kind_for` is `Free`). The plan, an enterprise's terms and a 100%
827+discount (flagon-io) count as paid; with payments off nothing is free.
828+Identity asks it (`services/identity/src/paid.rs`) and refuses with
829+`payment_required`:
830+
831+| Where | Refused when |
832+| --- | --- |
833+| `create_workspace` | The person owns any free workspace. Several from before are kept (grandfathered); none can be added until each is paid for or deleted. |
834+| `add_member`, `invite_member` | The workspace is free. |
835+| `add_collaborator` | Someone outside a free workspace (a username who is not a member, or an address). Members' roles are fine. |
836+| `accept_invite`, `respond_repo_invitation` | The invite's workspace (or repository's) is free now: it waits. A sign-up with such an invite makes the account without joining. |
837+
838+`@g1t` is never counted as someone added. If billing cannot be asked, the
839+change is refused for now ("try again"), never let through. The site says
840+so first (New workspace, People, a repository's Access, from the same RPC);
841+the API and MCP pass identity's refusal on as `402`.
842+
714843 ## The Security and quality activation
715844
716845 A second monthly subscription a workspace can hold beside the plan
+30−0
989989 stops work until paid. The owner's own spend limit always means stop.
990990 Test-mode payments never lower exposure or raise trust.
991991
992+### Tax, card fees and free workspaces (built 2026-10-08)
993+
994+> **2026-10-08, decided:** Stripe Tax everywhere ("so I don't fuck up on
995+> taxes"); Stripe's card fee passed to the customer with the 20% markup
996+> kept; one free workspace per person; no invites on free workspaces.
997+
998+- **Stripe Tax on every payment.** `automatic_tax` on every Checkout page
999+ (plan, Security and quality, prepaying, AI credit), every subscription
1000+ and every invoice g1t makes (month close, threshold, enterprise), and a
1001+ tax calculation and transaction for auto-reload's off-session charge.
1002+ Tax code `txcd_10103001` (SaaS, business use), every price
1003+ `tax_behavior=exclusive`. Checkout always collects the billing address
1004+ and tax ID and saves them on the customer. Prices on g1t are shown
1005+ excluding tax. Tax is never revenue: balances and plan payments are
1006+ credited without it, it is kept in `tax_and_fees`, shown as its own
1007+ statement line, and as **Tax collected** on sudo's Costs. Without an
1008+ address g1t does not charge: the owners are asked for one.
1009+- **The card fee** (2.9% + $0.30, grossed up) is its own line on every
1010+ card payment, the plan's and Security's as a monthly item; never on a
1011+ bank transfer or an enterprise's invoice. On by default
1012+ (`cost_settings.card_fee`). Not revenue either. Meters stay at cost +
1013+ 20%; models at the provider's price plus the agent rate.
1014+- **One free workspace per person.** Identity asks billing
1015+ (`free_workspaces`) before creating one; a second is refused with the
1016+ way forward. Those who own several from before keep them.
1017+- **A free workspace adds no one**: no members, invites or outside
1018+ collaborators until it starts the plan; its members stay; @g1t never
1019+ counts. Enforced in identity for every path (site, API, MCP).
1020+- Details: docs/BILLING_OPERATIONS.md, *Tax and the card fee*.
1021+
9921022 ### Promo codes (planned)
9931023
9941024 Staff credits (promotional, goodwill, refund; `services/billing/src/grants.rs`,