Vibecode Payhip
track this build5 phases, 15 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Payhip, sell downloads and simple courses through Stripe or PayPal. The hard boundary is payment integration, eu and uk vat handling, affiliates, courses, and hosted delivery, plus payments, tax, distribution, and operations.
You are building a lean indie version of Payhip. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Payhip · indie build A storefront for your own downloadable products: hosted checkout so card details never reach you, location evidence collected at purchase, VAT charged at the customer's rate where required, secure time-limited delivery of the file, and records an accountant can file from. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Django 5 with PostgreSQL | orders and tax records are money-adjacent and want a real database and a real admin | | Payments | Stripe Checkout with webhooks | hosted checkout keeps card data entirely out of your scope, which is the only sane starting position | | Money | Integer minor units throughout | tax arithmetic on floating point produces returns that disagree with your payment processor | | Delivery | Signed, expiring links to private object storage | a download link that never expires is a product being shared, and object storage is where large files belong | | Tax | Rates as dated data, evidence captured at checkout | rates change and evidence cannot be reconstructed after the sale | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the storefront. - Get it: python.org or your package manager, then a virtual environment. - Verify: python3 --version prints 3.12 or higher - [ ] **PostgreSQL 16 or newer** · free - Why: Orders and tax records deserve a real database. - Get it: Install locally or run it in Docker. - Verify: psql connects and reports 16 or higher - [ ] **A Stripe account with test mode keys** · free in test mode; live sales cost per transaction - Why: Hosted checkout keeps card data out of your scope entirely, which is the only reasonable starting position for a solo build. - Get it: Sign up, take the keys from Developers then API keys, and the webhook signing secret from the endpoint or the listen command. - Verify: A triggered test event reaches your endpoint and is verified - [ ] **Your own tax position, confirmed** · an hour of an accountant's time, and it is the cheapest part of getting this wrong - Why: This is the prerequisite that actually blocks launch. Where you are established determines whether a threshold applies, and selling into the European Union from outside it means owing from the first sale. - Get it: Speak to an accountant familiar with cross-border digital sales, or read your own revenue authority's guidance on digital services and the One Stop Shop scheme. Record what you concluded and when. - Verify: A written note states your registration position, the scheme and the date confirmed - [ ] **Private object storage** · storage is cheap; egress scales with sales - Why: Products are files and they must not be publicly readable, since a public URL is the product being given away. - Get it: A private bucket with credentials scoped to it, from any provider supporting signed URLs with short expiry. - Verify: An unsigned request is refused and a signed one succeeds ## Quick start ```sh pip install django stripe psycopg[binary] ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Tax advice or a determination of your registration position. - Acting as merchant of record and carrying the tax liability. - Marketplace discovery. - Affiliate programmes and bundled course delivery. - payment integration, EU and UK VAT handling, affiliates, courses, and hosted delivery - marketplace discovery - tax remittance - fraud operations - advanced course and community tooling If one of those is essential to you, that is the reason to keep paying for Payhip, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Payhip The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a storefront selling your own digital downloads, with the tax question answered before the first sale to replace Payhip. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Read this before writing a checkout, because it decides whether you can legally take the money. Selling digital products to consumers in the European Union means the place of supply is the customer's country, so you charge their rate rather than yours. As checked in September 2026: sellers established in the European Union have an EU-wide threshold of 10,000 euro a year covering cross-border business-to-consumer digital services and distance sales combined, and sellers established outside the European Union have no threshold at all and owe from the first sale. The One Stop Shop scheme lets you register in one member state and file one return covering all of them. The rules also require at least two pieces of non-contradictory evidence of where the customer is, such as billing address, network address and card issuing country. That is a checkout requirement, not a reporting one, so it has to be designed in rather than added. None of this is tax advice. Confirm your own position before selling, because the liability is yours. ### Stack (fixed, do not substitute) - Runtime: Django 5 with PostgreSQL · orders and tax records are money-adjacent and want a real database and a real admin - Payments: Stripe Checkout with webhooks · hosted checkout keeps card data entirely out of your scope, which is the only sane starting position - Money: Integer minor units throughout · tax arithmetic on floating point produces returns that disagree with your payment processor - Delivery: Signed, expiring links to private object storage · a download link that never expires is a product being shared, and object storage is where large files belong - Tax: Rates as dated data, evidence captured at checkout · rates change and evidence cannot be reconstructed after the sale ### Data model (create this before Phase 1) - products(id, name, slug, description, price_minor, currency, kind, active) · kind is download or bundle - files(id, product_id, storage_key, filename, bytes, version, uploaded_at) - orders(id, email, stripe_session_id, subtotal_minor, tax_minor, total_minor, currency, state, placed_at) - order_items(order_id, product_id, price_minor, tax_rate_bp, tax_minor) - location_evidence(order_id, kind, value_hash, collected_at) · at least two, non-contradictory, kept for the record - tax_rates(country, rate_bp, effective_from, source_url, checked_on) · dated, sourced, never hard coded - downloads(id, order_id, file_id, token, expires_at, used_count, max_uses) - The evidence rows are collected during checkout and kept, because you cannot go back and ask where a customer was. ### Phase 1 · Products and a checkout that takes no card data Build: Products with prices in integer minor units. A hosted checkout session created server side from your own price, never from a value the client sends. A webhook that verifies its signature and is the only thing that marks an order paid. Trap: Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. Done when: A test purchase marks the order paid only after the webhook. Replaying the event leaves one paid order. A bad signature is rejected with a 400. The price comes from the database and a modified client value has no effect, verified by tampering with the request. Do not build yet: No tax, no delivery, no records. ### Phase 2 · Location evidence, collected at the moment you can Build: Capture billing country from the checkout, the network address country, and the card issuing country where the processor reports it. Store them hashed where appropriate. Flag an order where the evidence disagrees rather than picking one silently. Trap: Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. Done when: Every completed order has at least two evidence rows. An order where billing country and network address country disagree is flagged for review rather than assigned automatically. Evidence is stored in a form that supports a return without retaining more personal data than needed. An order with only one piece of evidence is flagged. Do not build yet: No tax calculation yet. ### Phase 3 · Tax as dated data Build: A dated rates table with a source and a check date per country. Determine the rate from the evidence-derived country and the order date. Show tax separately at checkout. Record the rate and the rule applied on the order line. Trap: A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. Done when: An order to a country with a known rate charges it and shows it separately. Backdating an order to before a rate change applies the older rate. The rate and its source are recorded on the order line. A country with no rate row blocks the sale for review rather than charging zero silently. Do not build yet: No delivery or reporting yet. ### Phase 4 · Delivery that is not a public link Build: Store files in private object storage. On payment, issue a signed link with a short expiry and a use limit. Email it and make it re-requestable from an order lookup rather than resending forever. Log every download. Trap: A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. Done when: A download link works and stops working after its expiry, verified by waiting. Exceeding the use limit refuses and offers re-issue to the purchase email. A direct request to the storage object without a signature is refused. Every download is logged with a timestamp. Do not build yet: No reporting or refunds yet. ### Phase 5 · Refunds, and the records behind them Build: Handle refund events from the processor, reverse the order and its tax, and revoke outstanding download links. Produce a report per period showing sales and tax by country in the currency you file in. Trap: A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch. Done when: A refund reverses the order and its tax and revokes outstanding links, verified by attempting a download afterwards. A period report shows sales and tax by country and its totals match the processor's figures. Partial refunds reduce tax proportionally. The report exports in a form your accountant accepts. Do not build yet: Launch checks come last. ### Phase 6 · Before you take real money Build: Confirm where you are registered and under which scheme, and record it. Add the invoice or receipt details your jurisdiction requires. Deploy with TLS, back up the database and the files, and restore both. Trap: Launching before settling registration means either charging tax you are not registered to collect or collecting nothing and owing it anyway, and both are discovered later with interest attached. Settle it first; the code is the easy part of this build. Done when: The registration position is recorded with the scheme and the date it was confirmed. Receipts contain the fields your jurisdiction requires. A restore reproduces orders, tax records and file access, and the date is recorded. Live keys are only enabled after the restore drill. Do not build yet: Nothing after this. ### Out of scope (and why) - Tax advice. This charges a rate from a table you maintain and produces records you file from; whether you are registered correctly is a question for an accountant. - Tax remittance. The hosted product can act as merchant of record and take the liability; you are now the seller and the liability is yours. - Marketplace discovery. Nobody finds your storefront by browsing, which is a real part of what a platform provides. - Affiliate programmes and the course delivery features bundled into the paid product. ### README must contain - That the place of supply for digital sales is the customer's country, with the threshold position stated for your establishment - That two non-contradictory pieces of location evidence are captured at checkout and cannot be collected later - That tax rates are dated data with sources and are resolved by order date - That download links are short-lived and re-issuable rather than permanent - The registration position, the scheme, and the date it was confirmed ===== AGENTS.md ===== # Agent instructions · Payhip indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Django 5 with PostgreSQL, Stripe Checkout with webhooks, Integer minor units throughout, Signed, expiring links to private object storage, Rates as dated data, evidence captured at checkout. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. - Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. - A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. - A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. - A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch. ===== BUILD_PLAN.md ===== # Build plan · Payhip A storefront for your own downloadable products: hosted checkout so card details never reach you, location evidence collected at purchase, VAT charged at the customer's rate where required, secure time-limited delivery of the file, and records an accountant can file from. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Products and a checkout that takes no card data Someone can buy, and no card detail ever touches your server. ### Steps 1. Model products with integer prices Minor units, one currency to start. Formatting only for display. Files: `app/models.py` ```sh pip install django stripe psycopg[binary] ``` 2. Create sessions server side Price looked up from your database. Never trust an amount from the client. Files: `app/checkout.py` 3. Make the webhook the only writer Signature verified, event ids recorded for idempotency. Files: `app/webhooks.py` ### Done when - [ ] An order is paid only after the webhook - [ ] A replayed event leaves one paid order - [ ] A bad signature is rejected - [ ] A tampered client price has no effect ### Watch out - Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. ## Phase 2 · Location evidence, collected at the moment you can Every order carries at least two non-contradictory pieces of evidence about where the customer was. ### Steps 1. Capture from three sources Billing address, network address country and card issuing country. Two agreeing is the requirement. Files: `app/evidence.py` 2. Flag contradictions Do not pick. A disagreement is a decision with liability attached and belongs to a person. Files: `app/evidence.py` 3. Store proportionately Enough to substantiate a return, hashed where the raw value is not needed. Files: `app/evidence.py` ### Done when - [ ] Every order has at least two evidence rows - [ ] A contradiction is flagged rather than resolved automatically - [ ] An order with one piece of evidence is flagged - [ ] Stored evidence supports a return without excess retention ### Watch out - Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. ## Phase 3 · Tax as dated data The right rate is charged and you can show why, months later. ### Steps 1. Build the dated rates table Country, rate, effective date, source, check date. Enter only the countries you sell to. Files: `data/vat_rates.json` 2. Resolve by order date Never by today. A refund or a correction months later needs the rate that applied. Files: `app/tax.py` 3. Block rather than assume An unknown country stops the sale for review. Charging zero silently is the expensive failure. Files: `app/tax.py` ### Done when - [ ] A known country is charged its rate, shown separately - [ ] A backdated order applies the older rate - [ ] The rate and source are recorded per line - [ ] An unknown country blocks rather than charging zero ### Watch out - A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. ## Phase 4 · Delivery that is not a public link The buyer gets the file and the link does not circulate. ### Steps 1. Keep the bucket private Test an unsigned request and confirm refusal before relying on signing. 2. Issue short-lived signed links With a use limit, re-issuable to the purchase email rather than unlimited. Files: `app/delivery.py` 3. Log downloads Timestamp and count. It is how you notice a link circulating. Files: `app/delivery.py` ### Done when - [ ] A link expires and stops working - [ ] Exceeding the use limit refuses and offers re-issue - [ ] An unsigned storage request is refused - [ ] Downloads are logged ### Watch out - A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. ## Phase 5 · Refunds, and the records behind them A refund reverses the sale correctly, including its tax. ### Steps 1. Handle refund and dispute events Reverse the order, reverse the tax, revoke links. Trigger both in test mode. Files: `app/webhooks.py` ```sh stripe trigger charge.refunded ``` 2. Report by country and period What a return needs. In your filing currency, with the conversion recorded. Files: `app/reports.py` 3. Reconcile against the processor Your totals against theirs for the same window. A gap is a bug rather than a rounding difference. ### Done when - [ ] A refund reverses tax and revokes download links - [ ] Period totals match the processor's figures - [ ] A partial refund reduces tax proportionally - [ ] The report exports in an accepted format ### Watch out - A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch. ## Not in this build - Tax advice or a determination of your registration position. - Acting as merchant of record and carrying the tax liability. - Marketplace discovery. - Affiliate programmes and bundled course delivery. ## After v1, if you want it - A second currency, once the conversion and filing questions are settled - Licence keys for software products, which need their own validation endpoint - Customer-facing order history so re-download does not need an email round trip ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Orders and tax records. DATABASE_URL=postgres://user:pass@localhost:5432/shop # Required · secret. Stay in test mode until the restore drill and the tax position are both done. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. Private bucket holding the products themselves. FILES_BUCKET=product-files # Required. How long a download link lasts. Short enough that it does not circulate. DOWNLOAD_TTL_SECONDS=86400 # Required. Uses before a link must be re-issued to the purchase email. DOWNLOAD_MAX_USES=5 # Required. Dated rates with sources. No rate appears in code. TAX_RATES_PATH=./data/vat_rates.json # Required. Currency your returns are filed in. Conversions are recorded on the order. FILING_CURRENCY=GBP
You are building a lean indie version of Payhip. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Payhip · indie build A storefront for your own downloadable products: hosted checkout so card details never reach you, location evidence collected at purchase, VAT charged at the customer's rate where required, secure time-limited delivery of the file, and records an accountant can file from. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Django 5 with PostgreSQL | orders and tax records are money-adjacent and want a real database and a real admin | | Payments | Stripe Checkout with webhooks | hosted checkout keeps card data entirely out of your scope, which is the only sane starting position | | Money | Integer minor units throughout | tax arithmetic on floating point produces returns that disagree with your payment processor | | Delivery | Signed, expiring links to private object storage | a download link that never expires is a product being shared, and object storage is where large files belong | | Tax | Rates as dated data, evidence captured at checkout | rates change and evidence cannot be reconstructed after the sale | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the storefront. - Get it: python.org or your package manager, then a virtual environment. - Verify: python3 --version prints 3.12 or higher - [ ] **PostgreSQL 16 or newer** · free - Why: Orders and tax records deserve a real database. - Get it: Install locally or run it in Docker. - Verify: psql connects and reports 16 or higher - [ ] **A Stripe account with test mode keys** · free in test mode; live sales cost per transaction - Why: Hosted checkout keeps card data out of your scope entirely, which is the only reasonable starting position for a solo build. - Get it: Sign up, take the keys from Developers then API keys, and the webhook signing secret from the endpoint or the listen command. - Verify: A triggered test event reaches your endpoint and is verified - [ ] **Your own tax position, confirmed** · an hour of an accountant's time, and it is the cheapest part of getting this wrong - Why: This is the prerequisite that actually blocks launch. Where you are established determines whether a threshold applies, and selling into the European Union from outside it means owing from the first sale. - Get it: Speak to an accountant familiar with cross-border digital sales, or read your own revenue authority's guidance on digital services and the One Stop Shop scheme. Record what you concluded and when. - Verify: A written note states your registration position, the scheme and the date confirmed - [ ] **Private object storage** · storage is cheap; egress scales with sales - Why: Products are files and they must not be publicly readable, since a public URL is the product being given away. - Get it: A private bucket with credentials scoped to it, from any provider supporting signed URLs with short expiry. - Verify: An unsigned request is refused and a signed one succeeds ## Quick start ```sh pip install django stripe psycopg[binary] ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Tax advice or a determination of your registration position. - Acting as merchant of record and carrying the tax liability. - Marketplace discovery. - Affiliate programmes and bundled course delivery. - payment integration, EU and UK VAT handling, affiliates, courses, and hosted delivery - marketplace discovery - tax remittance - fraud operations - advanced course and community tooling If one of those is essential to you, that is the reason to keep paying for Payhip, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Payhip The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a storefront selling your own digital downloads, with the tax question answered before the first sale to replace Payhip. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Read this before writing a checkout, because it decides whether you can legally take the money. Selling digital products to consumers in the European Union means the place of supply is the customer's country, so you charge their rate rather than yours. As checked in September 2026: sellers established in the European Union have an EU-wide threshold of 10,000 euro a year covering cross-border business-to-consumer digital services and distance sales combined, and sellers established outside the European Union have no threshold at all and owe from the first sale. The One Stop Shop scheme lets you register in one member state and file one return covering all of them. The rules also require at least two pieces of non-contradictory evidence of where the customer is, such as billing address, network address and card issuing country. That is a checkout requirement, not a reporting one, so it has to be designed in rather than added. None of this is tax advice. Confirm your own position before selling, because the liability is yours. ### Stack (fixed, do not substitute) - Runtime: Django 5 with PostgreSQL · orders and tax records are money-adjacent and want a real database and a real admin - Payments: Stripe Checkout with webhooks · hosted checkout keeps card data entirely out of your scope, which is the only sane starting position - Money: Integer minor units throughout · tax arithmetic on floating point produces returns that disagree with your payment processor - Delivery: Signed, expiring links to private object storage · a download link that never expires is a product being shared, and object storage is where large files belong - Tax: Rates as dated data, evidence captured at checkout · rates change and evidence cannot be reconstructed after the sale ### Data model (create this before Phase 1) - products(id, name, slug, description, price_minor, currency, kind, active) · kind is download or bundle - files(id, product_id, storage_key, filename, bytes, version, uploaded_at) - orders(id, email, stripe_session_id, subtotal_minor, tax_minor, total_minor, currency, state, placed_at) - order_items(order_id, product_id, price_minor, tax_rate_bp, tax_minor) - location_evidence(order_id, kind, value_hash, collected_at) · at least two, non-contradictory, kept for the record - tax_rates(country, rate_bp, effective_from, source_url, checked_on) · dated, sourced, never hard coded - downloads(id, order_id, file_id, token, expires_at, used_count, max_uses) - The evidence rows are collected during checkout and kept, because you cannot go back and ask where a customer was. ### Phase 1 · Products and a checkout that takes no card data Build: Products with prices in integer minor units. A hosted checkout session created server side from your own price, never from a value the client sends. A webhook that verifies its signature and is the only thing that marks an order paid. Trap: Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. Done when: A test purchase marks the order paid only after the webhook. Replaying the event leaves one paid order. A bad signature is rejected with a 400. The price comes from the database and a modified client value has no effect, verified by tampering with the request. Do not build yet: No tax, no delivery, no records. ### Phase 2 · Location evidence, collected at the moment you can Build: Capture billing country from the checkout, the network address country, and the card issuing country where the processor reports it. Store them hashed where appropriate. Flag an order where the evidence disagrees rather than picking one silently. Trap: Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. Done when: Every completed order has at least two evidence rows. An order where billing country and network address country disagree is flagged for review rather than assigned automatically. Evidence is stored in a form that supports a return without retaining more personal data than needed. An order with only one piece of evidence is flagged. Do not build yet: No tax calculation yet. ### Phase 3 · Tax as dated data Build: A dated rates table with a source and a check date per country. Determine the rate from the evidence-derived country and the order date. Show tax separately at checkout. Record the rate and the rule applied on the order line. Trap: A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. Done when: An order to a country with a known rate charges it and shows it separately. Backdating an order to before a rate change applies the older rate. The rate and its source are recorded on the order line. A country with no rate row blocks the sale for review rather than charging zero silently. Do not build yet: No delivery or reporting yet. ### Phase 4 · Delivery that is not a public link Build: Store files in private object storage. On payment, issue a signed link with a short expiry and a use limit. Email it and make it re-requestable from an order lookup rather than resending forever. Log every download. Trap: A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. Done when: A download link works and stops working after its expiry, verified by waiting. Exceeding the use limit refuses and offers re-issue to the purchase email. A direct request to the storage object without a signature is refused. Every download is logged with a timestamp. Do not build yet: No reporting or refunds yet. ### Phase 5 · Refunds, and the records behind them Build: Handle refund events from the processor, reverse the order and its tax, and revoke outstanding download links. Produce a report per period showing sales and tax by country in the currency you file in. Trap: A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch. Done when: A refund reverses the order and its tax and revokes outstanding links, verified by attempting a download afterwards. A period report shows sales and tax by country and its totals match the processor's figures. Partial refunds reduce tax proportionally. The report exports in a form your accountant accepts. Do not build yet: Launch checks come last. ### Phase 6 · Before you take real money Build: Confirm where you are registered and under which scheme, and record it. Add the invoice or receipt details your jurisdiction requires. Deploy with TLS, back up the database and the files, and restore both. Trap: Launching before settling registration means either charging tax you are not registered to collect or collecting nothing and owing it anyway, and both are discovered later with interest attached. Settle it first; the code is the easy part of this build. Done when: The registration position is recorded with the scheme and the date it was confirmed. Receipts contain the fields your jurisdiction requires. A restore reproduces orders, tax records and file access, and the date is recorded. Live keys are only enabled after the restore drill. Do not build yet: Nothing after this. ### Out of scope (and why) - Tax advice. This charges a rate from a table you maintain and produces records you file from; whether you are registered correctly is a question for an accountant. - Tax remittance. The hosted product can act as merchant of record and take the liability; you are now the seller and the liability is yours. - Marketplace discovery. Nobody finds your storefront by browsing, which is a real part of what a platform provides. - Affiliate programmes and the course delivery features bundled into the paid product. ### README must contain - That the place of supply for digital sales is the customer's country, with the threshold position stated for your establishment - That two non-contradictory pieces of location evidence are captured at checkout and cannot be collected later - That tax rates are dated data with sources and are resolved by order date - That download links are short-lived and re-issuable rather than permanent - The registration position, the scheme, and the date it was confirmed ===== AGENTS.md ===== # Agent instructions · Payhip indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Django 5 with PostgreSQL, Stripe Checkout with webhooks, Integer minor units throughout, Signed, expiring links to private object storage, Rates as dated data, evidence captured at checkout. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. - Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. - A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. - A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. - A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch. ===== BUILD_PLAN.md ===== # Build plan · Payhip A storefront for your own downloadable products: hosted checkout so card details never reach you, location evidence collected at purchase, VAT charged at the customer's rate where required, secure time-limited delivery of the file, and records an accountant can file from. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Products and a checkout that takes no card data Someone can buy, and no card detail ever touches your server. ### Steps 1. Model products with integer prices Minor units, one currency to start. Formatting only for display. Files: `app/models.py` ```sh pip install django stripe psycopg[binary] ``` 2. Create sessions server side Price looked up from your database. Never trust an amount from the client. Files: `app/checkout.py` 3. Make the webhook the only writer Signature verified, event ids recorded for idempotency. Files: `app/webhooks.py` ### Done when - [ ] An order is paid only after the webhook - [ ] A replayed event leaves one paid order - [ ] A bad signature is rejected - [ ] A tampered client price has no effect ### Watch out - Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. ## Phase 2 · Location evidence, collected at the moment you can Every order carries at least two non-contradictory pieces of evidence about where the customer was. ### Steps 1. Capture from three sources Billing address, network address country and card issuing country. Two agreeing is the requirement. Files: `app/evidence.py` 2. Flag contradictions Do not pick. A disagreement is a decision with liability attached and belongs to a person. Files: `app/evidence.py` 3. Store proportionately Enough to substantiate a return, hashed where the raw value is not needed. Files: `app/evidence.py` ### Done when - [ ] Every order has at least two evidence rows - [ ] A contradiction is flagged rather than resolved automatically - [ ] An order with one piece of evidence is flagged - [ ] Stored evidence supports a return without excess retention ### Watch out - Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. ## Phase 3 · Tax as dated data The right rate is charged and you can show why, months later. ### Steps 1. Build the dated rates table Country, rate, effective date, source, check date. Enter only the countries you sell to. Files: `data/vat_rates.json` 2. Resolve by order date Never by today. A refund or a correction months later needs the rate that applied. Files: `app/tax.py` 3. Block rather than assume An unknown country stops the sale for review. Charging zero silently is the expensive failure. Files: `app/tax.py` ### Done when - [ ] A known country is charged its rate, shown separately - [ ] A backdated order applies the older rate - [ ] The rate and source are recorded per line - [ ] An unknown country blocks rather than charging zero ### Watch out - A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. ## Phase 4 · Delivery that is not a public link The buyer gets the file and the link does not circulate. ### Steps 1. Keep the bucket private Test an unsigned request and confirm refusal before relying on signing. 2. Issue short-lived signed links With a use limit, re-issuable to the purchase email rather than unlimited. Files: `app/delivery.py` 3. Log downloads Timestamp and count. It is how you notice a link circulating. Files: `app/delivery.py` ### Done when - [ ] A link expires and stops working - [ ] Exceeding the use limit refuses and offers re-issue - [ ] An unsigned storage request is refused - [ ] Downloads are logged ### Watch out - A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. ## Phase 5 · Refunds, and the records behind them A refund reverses the sale correctly, including its tax. ### Steps 1. Handle refund and dispute events Reverse the order, reverse the tax, revoke links. Trigger both in test mode. Files: `app/webhooks.py` ```sh stripe trigger charge.refunded ``` 2. Report by country and period What a return needs. In your filing currency, with the conversion recorded. Files: `app/reports.py` 3. Reconcile against the processor Your totals against theirs for the same window. A gap is a bug rather than a rounding difference. ### Done when - [ ] A refund reverses tax and revokes download links - [ ] Period totals match the processor's figures - [ ] A partial refund reduces tax proportionally - [ ] The report exports in an accepted format ### Watch out - A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch. ## Not in this build - Tax advice or a determination of your registration position. - Acting as merchant of record and carrying the tax liability. - Marketplace discovery. - Affiliate programmes and bundled course delivery. ## After v1, if you want it - A second currency, once the conversion and filing questions are settled - Licence keys for software products, which need their own validation endpoint - Customer-facing order history so re-download does not need an email round trip ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Orders and tax records. DATABASE_URL=postgres://user:pass@localhost:5432/shop # Required · secret. Stay in test mode until the restore drill and the tax position are both done. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. Private bucket holding the products themselves. FILES_BUCKET=product-files # Required. How long a download link lasts. Short enough that it does not circulate. DOWNLOAD_TTL_SECONDS=86400 # Required. Uses before a link must be re-issued to the purchase email. DOWNLOAD_MAX_USES=5 # Required. Dated rates with sources. No rate appears in code. TAX_RATES_PATH=./data/vat_rates.json # Required. Currency your returns are filed in. Conversions are recorded on the order. FILING_CURRENCY=GBP
You are building a production product version of Payhip. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Payhip · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Payhip, sell downloads and simple courses through Stripe or PayPal. The hard boundary is payment integration, eu and uk vat handling, affiliates, courses, and hosted delivery, plus payments, tax, distribution, and operations. ## Product outcome A storefront where the money is handled by a processor, the tax is charged at a rate you can justify with dated evidence, the files are delivered without becoming public, and the records support a return you can actually file. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Stripe account - PostgreSQL - S3-compatible storage - public HTTPS deployment - email provider ## Explicit non-goals for v1 - Tax advice or a determination of your registration position. - Acting as merchant of record and carrying the tax liability. - Marketplace discovery. - Affiliate programmes and bundled course delivery. - payment integration, EU and UK VAT handling, affiliates, courses, and hosted delivery - marketplace discovery - tax remittance - fraud operations - advanced course and community tooling ## Success criteria - The registration position is recorded and confirmed - Every order carries two non-contradictory evidence rows - A backdated order applies the correct historical rate - A refund is confirmed to reverse tax and revoke links - A restore is confirmed to reproduce orders and file access ===== BRIEF.md ===== # Build brief · Payhip The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a storefront selling your own digital downloads, with the tax question answered before the first sale to replace Payhip. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Read this before writing a checkout, because it decides whether you can legally take the money. Selling digital products to consumers in the European Union means the place of supply is the customer's country, so you charge their rate rather than yours. As checked in September 2026: sellers established in the European Union have an EU-wide threshold of 10,000 euro a year covering cross-border business-to-consumer digital services and distance sales combined, and sellers established outside the European Union have no threshold at all and owe from the first sale. The One Stop Shop scheme lets you register in one member state and file one return covering all of them. The rules also require at least two pieces of non-contradictory evidence of where the customer is, such as billing address, network address and card issuing country. That is a checkout requirement, not a reporting one, so it has to be designed in rather than added. None of this is tax advice. Confirm your own position before selling, because the liability is yours. ### Stack (fixed, do not substitute) - Runtime: Django 5 with PostgreSQL · orders and tax records are money-adjacent and want a real database and a real admin - Payments: Stripe Checkout with webhooks · hosted checkout keeps card data entirely out of your scope, which is the only sane starting position - Money: Integer minor units throughout · tax arithmetic on floating point produces returns that disagree with your payment processor - Delivery: Signed, expiring links to private object storage · a download link that never expires is a product being shared, and object storage is where large files belong - Tax: Rates as dated data, evidence captured at checkout · rates change and evidence cannot be reconstructed after the sale ### Data model (create this before Phase 1) - products(id, name, slug, description, price_minor, currency, kind, active) · kind is download or bundle - files(id, product_id, storage_key, filename, bytes, version, uploaded_at) - orders(id, email, stripe_session_id, subtotal_minor, tax_minor, total_minor, currency, state, placed_at) - order_items(order_id, product_id, price_minor, tax_rate_bp, tax_minor) - location_evidence(order_id, kind, value_hash, collected_at) · at least two, non-contradictory, kept for the record - tax_rates(country, rate_bp, effective_from, source_url, checked_on) · dated, sourced, never hard coded - downloads(id, order_id, file_id, token, expires_at, used_count, max_uses) - The evidence rows are collected during checkout and kept, because you cannot go back and ask where a customer was. ### Phase 1 · Products and a checkout that takes no card data Build: Products with prices in integer minor units. A hosted checkout session created server side from your own price, never from a value the client sends. A webhook that verifies its signature and is the only thing that marks an order paid. Trap: Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. Done when: A test purchase marks the order paid only after the webhook. Replaying the event leaves one paid order. A bad signature is rejected with a 400. The price comes from the database and a modified client value has no effect, verified by tampering with the request. Do not build yet: No tax, no delivery, no records. ### Phase 2 · Location evidence, collected at the moment you can Build: Capture billing country from the checkout, the network address country, and the card issuing country where the processor reports it. Store them hashed where appropriate. Flag an order where the evidence disagrees rather than picking one silently. Trap: Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. Done when: Every completed order has at least two evidence rows. An order where billing country and network address country disagree is flagged for review rather than assigned automatically. Evidence is stored in a form that supports a return without retaining more personal data than needed. An order with only one piece of evidence is flagged. Do not build yet: No tax calculation yet. ### Phase 3 · Tax as dated data Build: A dated rates table with a source and a check date per country. Determine the rate from the evidence-derived country and the order date. Show tax separately at checkout. Record the rate and the rule applied on the order line. Trap: A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. Done when: An order to a country with a known rate charges it and shows it separately. Backdating an order to before a rate change applies the older rate. The rate and its source are recorded on the order line. A country with no rate row blocks the sale for review rather than charging zero silently. Do not build yet: No delivery or reporting yet. ### Phase 4 · Delivery that is not a public link Build: Store files in private object storage. On payment, issue a signed link with a short expiry and a use limit. Email it and make it re-requestable from an order lookup rather than resending forever. Log every download. Trap: A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. Done when: A download link works and stops working after its expiry, verified by waiting. Exceeding the use limit refuses and offers re-issue to the purchase email. A direct request to the storage object without a signature is refused. Every download is logged with a timestamp. Do not build yet: No reporting or refunds yet. ### Phase 5 · Refunds, and the records behind them Build: Handle refund events from the processor, reverse the order and its tax, and revoke outstanding download links. Produce a report per period showing sales and tax by country in the currency you file in. Trap: A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch. Done when: A refund reverses the order and its tax and revokes outstanding links, verified by attempting a download afterwards. A period report shows sales and tax by country and its totals match the processor's figures. Partial refunds reduce tax proportionally. The report exports in a form your accountant accepts. Do not build yet: Launch checks come last. ### Phase 6 · Before you take real money Build: Confirm where you are registered and under which scheme, and record it. Add the invoice or receipt details your jurisdiction requires. Deploy with TLS, back up the database and the files, and restore both. Trap: Launching before settling registration means either charging tax you are not registered to collect or collecting nothing and owing it anyway, and both are discovered later with interest attached. Settle it first; the code is the easy part of this build. Done when: The registration position is recorded with the scheme and the date it was confirmed. Receipts contain the fields your jurisdiction requires. A restore reproduces orders, tax records and file access, and the date is recorded. Live keys are only enabled after the restore drill. Do not build yet: Nothing after this. ### Out of scope (and why) - Tax advice. This charges a rate from a table you maintain and produces records you file from; whether you are registered correctly is a question for an accountant. - Tax remittance. The hosted product can act as merchant of record and take the liability; you are now the seller and the liability is yours. - Marketplace discovery. Nobody finds your storefront by browsing, which is a real part of what a platform provides. - Affiliate programmes and the course delivery features bundled into the paid product. ### README must contain - That the place of supply for digital sales is the customer's country, with the threshold position stated for your establishment - That two non-contradictory pieces of location evidence are captured at checkout and cannot be collected later - That tax rates are dated data with sources and are resolved by order date - That download links are short-lived and re-issuable rather than permanent - The registration position, the scheme, and the date it was confirmed ===== ARCHITECTURE.md ===== # Architecture · Payhip ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Django 5 with PostgreSQL | orders and tax records are money-adjacent and want a real database and a real admin | | Payments | Stripe Checkout with webhooks | hosted checkout keeps card data entirely out of your scope, which is the only sane starting position | | Money | Integer minor units throughout | tax arithmetic on floating point produces returns that disagree with your payment processor | | Delivery | Signed, expiring links to private object storage | a download link that never expires is a product being shared, and object storage is where large files belong | | Tax | Rates as dated data, evidence captured at checkout | rates change and evidence cannot be reconstructed after the sale | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Checkout | Hosted payment and the webhook as source of truth. | Another processor changes the session call and signature check. The webhook-only rule survives. | | Evidence | Where the customer was. | Sources can change; the requirement for two non-contradictory pieces captured at checkout cannot, because it is not reconstructable later. | | Tax | Dated rates resolved by order date. | Any rate source. Rates in code is the failure mode. | | Delivery | Signed, expiring access to files. | Any storage with signed URLs. Permanent links are the thing being avoided. | | Reports | What you file from. | Format follows your accountant. Reconciling against the processor is the check that matters. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Orders and tax records. - `STRIPE_SECRET_KEY` · required, secret · Stay in test mode until the restore drill and the tax position are both done. - `STRIPE_WEBHOOK_SECRET` · required, secret · From the registered endpoint or the listen command. - `FILES_BUCKET` · required · Private bucket holding the products themselves. - `DOWNLOAD_TTL_SECONDS` · required · How long a download link lasts. Short enough that it does not circulate. - `DOWNLOAD_MAX_USES` · required · Uses before a link must be re-issued to the purchase email. - `TAX_RATES_PATH` · required · Dated rates with sources. No rate appears in code. - `FILING_CURRENCY` · required · Currency your returns are filed in. Conversions are recorded on the order. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Payhip product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Django 5 with PostgreSQL, Stripe Checkout with webhooks, Integer minor units throughout, Signed, expiring links to private object storage, Rates as dated data, evidence captured at checkout. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. - Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. - A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. - A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. - A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch. - Launching before settling registration means either charging tax you are not registered to collect or collecting nothing and owing it anyway, and both are discovered later with interest attached. Settle it first; the code is the easy part of this build. ===== MILESTONES.md ===== # Delivery milestones · Payhip Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Products and a checkout that takes no card data Someone can buy, and no card detail ever touches your server. ### Steps 1. Model products with integer prices Minor units, one currency to start. Formatting only for display. Files: `app/models.py` ```sh pip install django stripe psycopg[binary] ``` 2. Create sessions server side Price looked up from your database. Never trust an amount from the client. Files: `app/checkout.py` 3. Make the webhook the only writer Signature verified, event ids recorded for idempotency. Files: `app/webhooks.py` ### Done when - [ ] An order is paid only after the webhook - [ ] A replayed event leaves one paid order - [ ] A bad signature is rejected - [ ] A tampered client price has no effect ### Watch out - Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. ## M2 · Location evidence, collected at the moment you can Every order carries at least two non-contradictory pieces of evidence about where the customer was. ### Steps 1. Capture from three sources Billing address, network address country and card issuing country. Two agreeing is the requirement. Files: `app/evidence.py` 2. Flag contradictions Do not pick. A disagreement is a decision with liability attached and belongs to a person. Files: `app/evidence.py` 3. Store proportionately Enough to substantiate a return, hashed where the raw value is not needed. Files: `app/evidence.py` ### Done when - [ ] Every order has at least two evidence rows - [ ] A contradiction is flagged rather than resolved automatically - [ ] An order with one piece of evidence is flagged - [ ] Stored evidence supports a return without excess retention ### Watch out - Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. ## M3 · Tax as dated data The right rate is charged and you can show why, months later. ### Steps 1. Build the dated rates table Country, rate, effective date, source, check date. Enter only the countries you sell to. Files: `data/vat_rates.json` 2. Resolve by order date Never by today. A refund or a correction months later needs the rate that applied. Files: `app/tax.py` 3. Block rather than assume An unknown country stops the sale for review. Charging zero silently is the expensive failure. Files: `app/tax.py` ### Done when - [ ] A known country is charged its rate, shown separately - [ ] A backdated order applies the older rate - [ ] The rate and source are recorded per line - [ ] An unknown country blocks rather than charging zero ### Watch out - A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. ## M4 · Delivery that is not a public link The buyer gets the file and the link does not circulate. ### Steps 1. Keep the bucket private Test an unsigned request and confirm refusal before relying on signing. 2. Issue short-lived signed links With a use limit, re-issuable to the purchase email rather than unlimited. Files: `app/delivery.py` 3. Log downloads Timestamp and count. It is how you notice a link circulating. Files: `app/delivery.py` ### Done when - [ ] A link expires and stops working - [ ] Exceeding the use limit refuses and offers re-issue - [ ] An unsigned storage request is refused - [ ] Downloads are logged ### Watch out - A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. ## M5 · Refunds, and the records behind them A refund reverses the sale correctly, including its tax. ### Steps 1. Handle refund and dispute events Reverse the order, reverse the tax, revoke links. Trigger both in test mode. Files: `app/webhooks.py` ```sh stripe trigger charge.refunded ``` 2. Report by country and period What a return needs. In your filing currency, with the conversion recorded. Files: `app/reports.py` 3. Reconcile against the processor Your totals against theirs for the same window. A gap is a bug rather than a rounding difference. ### Done when - [ ] A refund reverses tax and revokes download links - [ ] Period totals match the processor's figures - [ ] A partial refund reduces tax proportionally - [ ] The report exports in an accepted format ### Watch out - A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch. ## M6 · Before you take real money (production only) Your registration position is settled and the launch checks have been done. ### Steps 1. Settle registration before launch Which scheme, which member state if applicable, and from what date. Recorded, with the advice you took. Files: `docs/tax-position.md` 2. Produce compliant receipts The fields your jurisdiction requires on a receipt or invoice for a digital sale. Files: `app/receipts.py` 3. Back up and restore both halves Database and files. A restore where orders exist and files are gone is not a restore. Files: `scripts/backup.sh`, `README.md` ### Done when - [ ] The registration position is recorded and dated - [ ] Receipts contain the required fields - [ ] A restore reproduces orders, tax records and file access - [ ] Live keys were enabled only after the restore drill ### Watch out - Launching before settling registration means either charging tax you are not registered to collect or collecting nothing and owing it anyway, and both are discovered later with interest attached. Settle it first; the code is the easy part of this build. ===== OPERATIONS.md ===== # Operations · Payhip ## Backup The database holds orders, tax records and evidence, which have retention obligations in most jurisdictions. The product files are the other half. Back up both, encrypted, and set retention from the obligation rather than convenience. ## Restore Restore both, then issue a download link for an old order and confirm it works. That is the check that proves the file storage came back too. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Webhook failures, orders flagged for evidence contradictions, and download counts per order. A rising download count on one order usually means a link circulated. ## Incident checklist If tax was charged at a wrong rate, the dated table and the recorded rate per line make a correction possible; recomputing with today's rates would not. If a link leaked, revoke it and re-issue, which is why links are revocable. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The registration position is recorded and confirmed - [ ] Every order carries two non-contradictory evidence rows - [ ] A backdated order applies the correct historical rate - [ ] A refund is confirmed to reverse tax and revoke links - [ ] A restore is confirmed to reproduce orders and file access ## Launch constraint Do not market omitted Payhip capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Orders and tax records. DATABASE_URL=postgres://user:pass@localhost:5432/shop # Required · secret. Stay in test mode until the restore drill and the tax position are both done. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. Private bucket holding the products themselves. FILES_BUCKET=product-files # Required. How long a download link lasts. Short enough that it does not circulate. DOWNLOAD_TTL_SECONDS=86400 # Required. Uses before a link must be re-issued to the purchase email. DOWNLOAD_MAX_USES=5 # Required. Dated rates with sources. No rate appears in code. TAX_RATES_PATH=./data/vat_rates.json # Required. Currency your returns are filed in. Conversions are recorded on the order. FILING_CURRENCY=GBP
# Payhip · indie build A storefront for your own downloadable products: hosted checkout so card details never reach you, location evidence collected at purchase, VAT charged at the customer's rate where required, secure time-limited delivery of the file, and records an accountant can file from. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Django 5 with PostgreSQL | orders and tax records are money-adjacent and want a real database and a real admin | | Payments | Stripe Checkout with webhooks | hosted checkout keeps card data entirely out of your scope, which is the only sane starting position | | Money | Integer minor units throughout | tax arithmetic on floating point produces returns that disagree with your payment processor | | Delivery | Signed, expiring links to private object storage | a download link that never expires is a product being shared, and object storage is where large files belong | | Tax | Rates as dated data, evidence captured at checkout | rates change and evidence cannot be reconstructed after the sale | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the storefront. - Get it: python.org or your package manager, then a virtual environment. - Verify: python3 --version prints 3.12 or higher - [ ] **PostgreSQL 16 or newer** · free - Why: Orders and tax records deserve a real database. - Get it: Install locally or run it in Docker. - Verify: psql connects and reports 16 or higher - [ ] **A Stripe account with test mode keys** · free in test mode; live sales cost per transaction - Why: Hosted checkout keeps card data out of your scope entirely, which is the only reasonable starting position for a solo build. - Get it: Sign up, take the keys from Developers then API keys, and the webhook signing secret from the endpoint or the listen command. - Verify: A triggered test event reaches your endpoint and is verified - [ ] **Your own tax position, confirmed** · an hour of an accountant's time, and it is the cheapest part of getting this wrong - Why: This is the prerequisite that actually blocks launch. Where you are established determines whether a threshold applies, and selling into the European Union from outside it means owing from the first sale. - Get it: Speak to an accountant familiar with cross-border digital sales, or read your own revenue authority's guidance on digital services and the One Stop Shop scheme. Record what you concluded and when. - Verify: A written note states your registration position, the scheme and the date confirmed - [ ] **Private object storage** · storage is cheap; egress scales with sales - Why: Products are files and they must not be publicly readable, since a public URL is the product being given away. - Get it: A private bucket with credentials scoped to it, from any provider supporting signed URLs with short expiry. - Verify: An unsigned request is refused and a signed one succeeds ## Quick start ```sh pip install django stripe psycopg[binary] ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Tax advice or a determination of your registration position. - Acting as merchant of record and carrying the tax liability. - Marketplace discovery. - Affiliate programmes and bundled course delivery. - payment integration, EU and UK VAT handling, affiliates, courses, and hosted delivery - marketplace discovery - tax remittance - fraud operations - advanced course and community tooling If one of those is essential to you, that is the reason to keep paying for Payhip, and the README should say so rather than pretend.
# Build brief · Payhip The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a storefront selling your own digital downloads, with the tax question answered before the first sale to replace Payhip. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Read this before writing a checkout, because it decides whether you can legally take the money. Selling digital products to consumers in the European Union means the place of supply is the customer's country, so you charge their rate rather than yours. As checked in September 2026: sellers established in the European Union have an EU-wide threshold of 10,000 euro a year covering cross-border business-to-consumer digital services and distance sales combined, and sellers established outside the European Union have no threshold at all and owe from the first sale. The One Stop Shop scheme lets you register in one member state and file one return covering all of them. The rules also require at least two pieces of non-contradictory evidence of where the customer is, such as billing address, network address and card issuing country. That is a checkout requirement, not a reporting one, so it has to be designed in rather than added. None of this is tax advice. Confirm your own position before selling, because the liability is yours. ### Stack (fixed, do not substitute) - Runtime: Django 5 with PostgreSQL · orders and tax records are money-adjacent and want a real database and a real admin - Payments: Stripe Checkout with webhooks · hosted checkout keeps card data entirely out of your scope, which is the only sane starting position - Money: Integer minor units throughout · tax arithmetic on floating point produces returns that disagree with your payment processor - Delivery: Signed, expiring links to private object storage · a download link that never expires is a product being shared, and object storage is where large files belong - Tax: Rates as dated data, evidence captured at checkout · rates change and evidence cannot be reconstructed after the sale ### Data model (create this before Phase 1) - products(id, name, slug, description, price_minor, currency, kind, active) · kind is download or bundle - files(id, product_id, storage_key, filename, bytes, version, uploaded_at) - orders(id, email, stripe_session_id, subtotal_minor, tax_minor, total_minor, currency, state, placed_at) - order_items(order_id, product_id, price_minor, tax_rate_bp, tax_minor) - location_evidence(order_id, kind, value_hash, collected_at) · at least two, non-contradictory, kept for the record - tax_rates(country, rate_bp, effective_from, source_url, checked_on) · dated, sourced, never hard coded - downloads(id, order_id, file_id, token, expires_at, used_count, max_uses) - The evidence rows are collected during checkout and kept, because you cannot go back and ask where a customer was. ### Phase 1 · Products and a checkout that takes no card data Build: Products with prices in integer minor units. A hosted checkout session created server side from your own price, never from a value the client sends. A webhook that verifies its signature and is the only thing that marks an order paid. Trap: Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. Done when: A test purchase marks the order paid only after the webhook. Replaying the event leaves one paid order. A bad signature is rejected with a 400. The price comes from the database and a modified client value has no effect, verified by tampering with the request. Do not build yet: No tax, no delivery, no records. ### Phase 2 · Location evidence, collected at the moment you can Build: Capture billing country from the checkout, the network address country, and the card issuing country where the processor reports it. Store them hashed where appropriate. Flag an order where the evidence disagrees rather than picking one silently. Trap: Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. Done when: Every completed order has at least two evidence rows. An order where billing country and network address country disagree is flagged for review rather than assigned automatically. Evidence is stored in a form that supports a return without retaining more personal data than needed. An order with only one piece of evidence is flagged. Do not build yet: No tax calculation yet. ### Phase 3 · Tax as dated data Build: A dated rates table with a source and a check date per country. Determine the rate from the evidence-derived country and the order date. Show tax separately at checkout. Record the rate and the rule applied on the order line. Trap: A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. Done when: An order to a country with a known rate charges it and shows it separately. Backdating an order to before a rate change applies the older rate. The rate and its source are recorded on the order line. A country with no rate row blocks the sale for review rather than charging zero silently. Do not build yet: No delivery or reporting yet. ### Phase 4 · Delivery that is not a public link Build: Store files in private object storage. On payment, issue a signed link with a short expiry and a use limit. Email it and make it re-requestable from an order lookup rather than resending forever. Log every download. Trap: A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. Done when: A download link works and stops working after its expiry, verified by waiting. Exceeding the use limit refuses and offers re-issue to the purchase email. A direct request to the storage object without a signature is refused. Every download is logged with a timestamp. Do not build yet: No reporting or refunds yet. ### Phase 5 · Refunds, and the records behind them Build: Handle refund events from the processor, reverse the order and its tax, and revoke outstanding download links. Produce a report per period showing sales and tax by country in the currency you file in. Trap: A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch. Done when: A refund reverses the order and its tax and revokes outstanding links, verified by attempting a download afterwards. A period report shows sales and tax by country and its totals match the processor's figures. Partial refunds reduce tax proportionally. The report exports in a form your accountant accepts. Do not build yet: Launch checks come last. ### Phase 6 · Before you take real money Build: Confirm where you are registered and under which scheme, and record it. Add the invoice or receipt details your jurisdiction requires. Deploy with TLS, back up the database and the files, and restore both. Trap: Launching before settling registration means either charging tax you are not registered to collect or collecting nothing and owing it anyway, and both are discovered later with interest attached. Settle it first; the code is the easy part of this build. Done when: The registration position is recorded with the scheme and the date it was confirmed. Receipts contain the fields your jurisdiction requires. A restore reproduces orders, tax records and file access, and the date is recorded. Live keys are only enabled after the restore drill. Do not build yet: Nothing after this. ### Out of scope (and why) - Tax advice. This charges a rate from a table you maintain and produces records you file from; whether you are registered correctly is a question for an accountant. - Tax remittance. The hosted product can act as merchant of record and take the liability; you are now the seller and the liability is yours. - Marketplace discovery. Nobody finds your storefront by browsing, which is a real part of what a platform provides. - Affiliate programmes and the course delivery features bundled into the paid product. ### README must contain - That the place of supply for digital sales is the customer's country, with the threshold position stated for your establishment - That two non-contradictory pieces of location evidence are captured at checkout and cannot be collected later - That tax rates are dated data with sources and are resolved by order date - That download links are short-lived and re-issuable rather than permanent - The registration position, the scheme, and the date it was confirmed
# Agent instructions · Payhip indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Django 5 with PostgreSQL, Stripe Checkout with webhooks, Integer minor units throughout, Signed, expiring links to private object storage, Rates as dated data, evidence captured at checkout. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. - Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. - A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. - A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. - A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch.
# Build plan · Payhip A storefront for your own downloadable products: hosted checkout so card details never reach you, location evidence collected at purchase, VAT charged at the customer's rate where required, secure time-limited delivery of the file, and records an accountant can file from. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Products and a checkout that takes no card data Someone can buy, and no card detail ever touches your server. ### Steps 1. Model products with integer prices Minor units, one currency to start. Formatting only for display. Files: `app/models.py` ```sh pip install django stripe psycopg[binary] ``` 2. Create sessions server side Price looked up from your database. Never trust an amount from the client. Files: `app/checkout.py` 3. Make the webhook the only writer Signature verified, event ids recorded for idempotency. Files: `app/webhooks.py` ### Done when - [ ] An order is paid only after the webhook - [ ] A replayed event leaves one paid order - [ ] A bad signature is rejected - [ ] A tampered client price has no effect ### Watch out - Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. ## Phase 2 · Location evidence, collected at the moment you can Every order carries at least two non-contradictory pieces of evidence about where the customer was. ### Steps 1. Capture from three sources Billing address, network address country and card issuing country. Two agreeing is the requirement. Files: `app/evidence.py` 2. Flag contradictions Do not pick. A disagreement is a decision with liability attached and belongs to a person. Files: `app/evidence.py` 3. Store proportionately Enough to substantiate a return, hashed where the raw value is not needed. Files: `app/evidence.py` ### Done when - [ ] Every order has at least two evidence rows - [ ] A contradiction is flagged rather than resolved automatically - [ ] An order with one piece of evidence is flagged - [ ] Stored evidence supports a return without excess retention ### Watch out - Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. ## Phase 3 · Tax as dated data The right rate is charged and you can show why, months later. ### Steps 1. Build the dated rates table Country, rate, effective date, source, check date. Enter only the countries you sell to. Files: `data/vat_rates.json` 2. Resolve by order date Never by today. A refund or a correction months later needs the rate that applied. Files: `app/tax.py` 3. Block rather than assume An unknown country stops the sale for review. Charging zero silently is the expensive failure. Files: `app/tax.py` ### Done when - [ ] A known country is charged its rate, shown separately - [ ] A backdated order applies the older rate - [ ] The rate and source are recorded per line - [ ] An unknown country blocks rather than charging zero ### Watch out - A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. ## Phase 4 · Delivery that is not a public link The buyer gets the file and the link does not circulate. ### Steps 1. Keep the bucket private Test an unsigned request and confirm refusal before relying on signing. 2. Issue short-lived signed links With a use limit, re-issuable to the purchase email rather than unlimited. Files: `app/delivery.py` 3. Log downloads Timestamp and count. It is how you notice a link circulating. Files: `app/delivery.py` ### Done when - [ ] A link expires and stops working - [ ] Exceeding the use limit refuses and offers re-issue - [ ] An unsigned storage request is refused - [ ] Downloads are logged ### Watch out - A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. ## Phase 5 · Refunds, and the records behind them A refund reverses the sale correctly, including its tax. ### Steps 1. Handle refund and dispute events Reverse the order, reverse the tax, revoke links. Trigger both in test mode. Files: `app/webhooks.py` ```sh stripe trigger charge.refunded ``` 2. Report by country and period What a return needs. In your filing currency, with the conversion recorded. Files: `app/reports.py` 3. Reconcile against the processor Your totals against theirs for the same window. A gap is a bug rather than a rounding difference. ### Done when - [ ] A refund reverses tax and revokes download links - [ ] Period totals match the processor's figures - [ ] A partial refund reduces tax proportionally - [ ] The report exports in an accepted format ### Watch out - A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch. ## Not in this build - Tax advice or a determination of your registration position. - Acting as merchant of record and carrying the tax liability. - Marketplace discovery. - Affiliate programmes and bundled course delivery. ## After v1, if you want it - A second currency, once the conversion and filing questions are settled - Licence keys for software products, which need their own validation endpoint - Customer-facing order history so re-download does not need an email round trip
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Orders and tax records. DATABASE_URL=postgres://user:pass@localhost:5432/shop # Required · secret. Stay in test mode until the restore drill and the tax position are both done. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. Private bucket holding the products themselves. FILES_BUCKET=product-files # Required. How long a download link lasts. Short enough that it does not circulate. DOWNLOAD_TTL_SECONDS=86400 # Required. Uses before a link must be re-issued to the purchase email. DOWNLOAD_MAX_USES=5 # Required. Dated rates with sources. No rate appears in code. TAX_RATES_PATH=./data/vat_rates.json # Required. Currency your returns are filed in. Conversions are recorded on the order. FILING_CURRENCY=GBP
# Payhip · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Payhip, sell downloads and simple courses through Stripe or PayPal. The hard boundary is payment integration, eu and uk vat handling, affiliates, courses, and hosted delivery, plus payments, tax, distribution, and operations. ## Product outcome A storefront where the money is handled by a processor, the tax is charged at a rate you can justify with dated evidence, the files are delivered without becoming public, and the records support a return you can actually file. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Stripe account - PostgreSQL - S3-compatible storage - public HTTPS deployment - email provider ## Explicit non-goals for v1 - Tax advice or a determination of your registration position. - Acting as merchant of record and carrying the tax liability. - Marketplace discovery. - Affiliate programmes and bundled course delivery. - payment integration, EU and UK VAT handling, affiliates, courses, and hosted delivery - marketplace discovery - tax remittance - fraud operations - advanced course and community tooling ## Success criteria - The registration position is recorded and confirmed - Every order carries two non-contradictory evidence rows - A backdated order applies the correct historical rate - A refund is confirmed to reverse tax and revoke links - A restore is confirmed to reproduce orders and file access
# Build brief · Payhip The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a storefront selling your own digital downloads, with the tax question answered before the first sale to replace Payhip. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Read this before writing a checkout, because it decides whether you can legally take the money. Selling digital products to consumers in the European Union means the place of supply is the customer's country, so you charge their rate rather than yours. As checked in September 2026: sellers established in the European Union have an EU-wide threshold of 10,000 euro a year covering cross-border business-to-consumer digital services and distance sales combined, and sellers established outside the European Union have no threshold at all and owe from the first sale. The One Stop Shop scheme lets you register in one member state and file one return covering all of them. The rules also require at least two pieces of non-contradictory evidence of where the customer is, such as billing address, network address and card issuing country. That is a checkout requirement, not a reporting one, so it has to be designed in rather than added. None of this is tax advice. Confirm your own position before selling, because the liability is yours. ### Stack (fixed, do not substitute) - Runtime: Django 5 with PostgreSQL · orders and tax records are money-adjacent and want a real database and a real admin - Payments: Stripe Checkout with webhooks · hosted checkout keeps card data entirely out of your scope, which is the only sane starting position - Money: Integer minor units throughout · tax arithmetic on floating point produces returns that disagree with your payment processor - Delivery: Signed, expiring links to private object storage · a download link that never expires is a product being shared, and object storage is where large files belong - Tax: Rates as dated data, evidence captured at checkout · rates change and evidence cannot be reconstructed after the sale ### Data model (create this before Phase 1) - products(id, name, slug, description, price_minor, currency, kind, active) · kind is download or bundle - files(id, product_id, storage_key, filename, bytes, version, uploaded_at) - orders(id, email, stripe_session_id, subtotal_minor, tax_minor, total_minor, currency, state, placed_at) - order_items(order_id, product_id, price_minor, tax_rate_bp, tax_minor) - location_evidence(order_id, kind, value_hash, collected_at) · at least two, non-contradictory, kept for the record - tax_rates(country, rate_bp, effective_from, source_url, checked_on) · dated, sourced, never hard coded - downloads(id, order_id, file_id, token, expires_at, used_count, max_uses) - The evidence rows are collected during checkout and kept, because you cannot go back and ask where a customer was. ### Phase 1 · Products and a checkout that takes no card data Build: Products with prices in integer minor units. A hosted checkout session created server side from your own price, never from a value the client sends. A webhook that verifies its signature and is the only thing that marks an order paid. Trap: Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. Done when: A test purchase marks the order paid only after the webhook. Replaying the event leaves one paid order. A bad signature is rejected with a 400. The price comes from the database and a modified client value has no effect, verified by tampering with the request. Do not build yet: No tax, no delivery, no records. ### Phase 2 · Location evidence, collected at the moment you can Build: Capture billing country from the checkout, the network address country, and the card issuing country where the processor reports it. Store them hashed where appropriate. Flag an order where the evidence disagrees rather than picking one silently. Trap: Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. Done when: Every completed order has at least two evidence rows. An order where billing country and network address country disagree is flagged for review rather than assigned automatically. Evidence is stored in a form that supports a return without retaining more personal data than needed. An order with only one piece of evidence is flagged. Do not build yet: No tax calculation yet. ### Phase 3 · Tax as dated data Build: A dated rates table with a source and a check date per country. Determine the rate from the evidence-derived country and the order date. Show tax separately at checkout. Record the rate and the rule applied on the order line. Trap: A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. Done when: An order to a country with a known rate charges it and shows it separately. Backdating an order to before a rate change applies the older rate. The rate and its source are recorded on the order line. A country with no rate row blocks the sale for review rather than charging zero silently. Do not build yet: No delivery or reporting yet. ### Phase 4 · Delivery that is not a public link Build: Store files in private object storage. On payment, issue a signed link with a short expiry and a use limit. Email it and make it re-requestable from an order lookup rather than resending forever. Log every download. Trap: A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. Done when: A download link works and stops working after its expiry, verified by waiting. Exceeding the use limit refuses and offers re-issue to the purchase email. A direct request to the storage object without a signature is refused. Every download is logged with a timestamp. Do not build yet: No reporting or refunds yet. ### Phase 5 · Refunds, and the records behind them Build: Handle refund events from the processor, reverse the order and its tax, and revoke outstanding download links. Produce a report per period showing sales and tax by country in the currency you file in. Trap: A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch. Done when: A refund reverses the order and its tax and revokes outstanding links, verified by attempting a download afterwards. A period report shows sales and tax by country and its totals match the processor's figures. Partial refunds reduce tax proportionally. The report exports in a form your accountant accepts. Do not build yet: Launch checks come last. ### Phase 6 · Before you take real money Build: Confirm where you are registered and under which scheme, and record it. Add the invoice or receipt details your jurisdiction requires. Deploy with TLS, back up the database and the files, and restore both. Trap: Launching before settling registration means either charging tax you are not registered to collect or collecting nothing and owing it anyway, and both are discovered later with interest attached. Settle it first; the code is the easy part of this build. Done when: The registration position is recorded with the scheme and the date it was confirmed. Receipts contain the fields your jurisdiction requires. A restore reproduces orders, tax records and file access, and the date is recorded. Live keys are only enabled after the restore drill. Do not build yet: Nothing after this. ### Out of scope (and why) - Tax advice. This charges a rate from a table you maintain and produces records you file from; whether you are registered correctly is a question for an accountant. - Tax remittance. The hosted product can act as merchant of record and take the liability; you are now the seller and the liability is yours. - Marketplace discovery. Nobody finds your storefront by browsing, which is a real part of what a platform provides. - Affiliate programmes and the course delivery features bundled into the paid product. ### README must contain - That the place of supply for digital sales is the customer's country, with the threshold position stated for your establishment - That two non-contradictory pieces of location evidence are captured at checkout and cannot be collected later - That tax rates are dated data with sources and are resolved by order date - That download links are short-lived and re-issuable rather than permanent - The registration position, the scheme, and the date it was confirmed
# Architecture · Payhip ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Django 5 with PostgreSQL | orders and tax records are money-adjacent and want a real database and a real admin | | Payments | Stripe Checkout with webhooks | hosted checkout keeps card data entirely out of your scope, which is the only sane starting position | | Money | Integer minor units throughout | tax arithmetic on floating point produces returns that disagree with your payment processor | | Delivery | Signed, expiring links to private object storage | a download link that never expires is a product being shared, and object storage is where large files belong | | Tax | Rates as dated data, evidence captured at checkout | rates change and evidence cannot be reconstructed after the sale | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Checkout | Hosted payment and the webhook as source of truth. | Another processor changes the session call and signature check. The webhook-only rule survives. | | Evidence | Where the customer was. | Sources can change; the requirement for two non-contradictory pieces captured at checkout cannot, because it is not reconstructable later. | | Tax | Dated rates resolved by order date. | Any rate source. Rates in code is the failure mode. | | Delivery | Signed, expiring access to files. | Any storage with signed URLs. Permanent links are the thing being avoided. | | Reports | What you file from. | Format follows your accountant. Reconciling against the processor is the check that matters. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Orders and tax records. - `STRIPE_SECRET_KEY` · required, secret · Stay in test mode until the restore drill and the tax position are both done. - `STRIPE_WEBHOOK_SECRET` · required, secret · From the registered endpoint or the listen command. - `FILES_BUCKET` · required · Private bucket holding the products themselves. - `DOWNLOAD_TTL_SECONDS` · required · How long a download link lasts. Short enough that it does not circulate. - `DOWNLOAD_MAX_USES` · required · Uses before a link must be re-issued to the purchase email. - `TAX_RATES_PATH` · required · Dated rates with sources. No rate appears in code. - `FILING_CURRENCY` · required · Currency your returns are filed in. Conversions are recorded on the order. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Payhip product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Django 5 with PostgreSQL, Stripe Checkout with webhooks, Integer minor units throughout, Signed, expiring links to private object storage, Rates as dated data, evidence captured at checkout. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. - Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. - A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. - A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. - A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch. - Launching before settling registration means either charging tax you are not registered to collect or collecting nothing and owing it anyway, and both are discovered later with interest attached. Settle it first; the code is the easy part of this build.
# Delivery milestones · Payhip Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Products and a checkout that takes no card data Someone can buy, and no card detail ever touches your server. ### Steps 1. Model products with integer prices Minor units, one currency to start. Formatting only for display. Files: `app/models.py` ```sh pip install django stripe psycopg[binary] ``` 2. Create sessions server side Price looked up from your database. Never trust an amount from the client. Files: `app/checkout.py` 3. Make the webhook the only writer Signature verified, event ids recorded for idempotency. Files: `app/webhooks.py` ### Done when - [ ] An order is paid only after the webhook - [ ] A replayed event leaves one paid order - [ ] A bad signature is rejected - [ ] A tampered client price has no effect ### Watch out - Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first. ## M2 · Location evidence, collected at the moment you can Every order carries at least two non-contradictory pieces of evidence about where the customer was. ### Steps 1. Capture from three sources Billing address, network address country and card issuing country. Two agreeing is the requirement. Files: `app/evidence.py` 2. Flag contradictions Do not pick. A disagreement is a decision with liability attached and belongs to a person. Files: `app/evidence.py` 3. Store proportionately Enough to substantiate a return, hashed where the raw value is not needed. Files: `app/evidence.py` ### Done when - [ ] Every order has at least two evidence rows - [ ] A contradiction is flagged rather than resolved automatically - [ ] An order with one piece of evidence is flagged - [ ] Stored evidence supports a return without excess retention ### Watch out - Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves. ## M3 · Tax as dated data The right rate is charged and you can show why, months later. ### Steps 1. Build the dated rates table Country, rate, effective date, source, check date. Enter only the countries you sell to. Files: `data/vat_rates.json` 2. Resolve by order date Never by today. A refund or a correction months later needs the rate that applied. Files: `app/tax.py` 3. Block rather than assume An unknown country stops the sale for review. Charging zero silently is the expensive failure. Files: `app/tax.py` ### Done when - [ ] A known country is charged its rate, shown separately - [ ] A backdated order applies the older rate - [ ] The rate and source are recorded per line - [ ] An unknown country blocks rather than charging zero ### Watch out - A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers. ## M4 · Delivery that is not a public link The buyer gets the file and the link does not circulate. ### Steps 1. Keep the bucket private Test an unsigned request and confirm refusal before relying on signing. 2. Issue short-lived signed links With a use limit, re-issuable to the purchase email rather than unlimited. Files: `app/delivery.py` 3. Log downloads Timestamp and count. It is how you notice a link circulating. Files: `app/delivery.py` ### Done when - [ ] A link expires and stops working - [ ] Exceeding the use limit refuses and offers re-issue - [ ] An unsigned storage request is refused - [ ] Downloads are logged ### Watch out - A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel. ## M5 · Refunds, and the records behind them A refund reverses the sale correctly, including its tax. ### Steps 1. Handle refund and dispute events Reverse the order, reverse the tax, revoke links. Trigger both in test mode. Files: `app/webhooks.py` ```sh stripe trigger charge.refunded ``` 2. Report by country and period What a return needs. In your filing currency, with the conversion recorded. Files: `app/reports.py` 3. Reconcile against the processor Your totals against theirs for the same window. A gap is a bug rather than a rounding difference. ### Done when - [ ] A refund reverses tax and revokes download links - [ ] Period totals match the processor's figures - [ ] A partial refund reduces tax proportionally - [ ] The report exports in an accepted format ### Watch out - A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch. ## M6 · Before you take real money (production only) Your registration position is settled and the launch checks have been done. ### Steps 1. Settle registration before launch Which scheme, which member state if applicable, and from what date. Recorded, with the advice you took. Files: `docs/tax-position.md` 2. Produce compliant receipts The fields your jurisdiction requires on a receipt or invoice for a digital sale. Files: `app/receipts.py` 3. Back up and restore both halves Database and files. A restore where orders exist and files are gone is not a restore. Files: `scripts/backup.sh`, `README.md` ### Done when - [ ] The registration position is recorded and dated - [ ] Receipts contain the required fields - [ ] A restore reproduces orders, tax records and file access - [ ] Live keys were enabled only after the restore drill ### Watch out - Launching before settling registration means either charging tax you are not registered to collect or collecting nothing and owing it anyway, and both are discovered later with interest attached. Settle it first; the code is the easy part of this build.
# Operations · Payhip ## Backup The database holds orders, tax records and evidence, which have retention obligations in most jurisdictions. The product files are the other half. Back up both, encrypted, and set retention from the obligation rather than convenience. ## Restore Restore both, then issue a download link for an old order and confirm it works. That is the check that proves the file storage came back too. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Webhook failures, orders flagged for evidence contradictions, and download counts per order. A rising download count on one order usually means a link circulated. ## Incident checklist If tax was charged at a wrong rate, the dated table and the recorded rate per line make a correction possible; recomputing with today's rates would not. If a link leaked, revoke it and re-issue, which is why links are revocable. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The registration position is recorded and confirmed - [ ] Every order carries two non-contradictory evidence rows - [ ] A backdated order applies the correct historical rate - [ ] A refund is confirmed to reverse tax and revoke links - [ ] A restore is confirmed to reproduce orders and file access ## Launch constraint Do not market omitted Payhip capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Orders and tax records. DATABASE_URL=postgres://user:pass@localhost:5432/shop # Required · secret. Stay in test mode until the restore drill and the tax position are both done. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. Private bucket holding the products themselves. FILES_BUCKET=product-files # Required. How long a download link lasts. Short enough that it does not circulate. DOWNLOAD_TTL_SECONDS=86400 # Required. Uses before a link must be re-issued to the purchase email. DOWNLOAD_MAX_USES=5 # Required. Dated rates with sources. No rate appears in code. TAX_RATES_PATH=./data/vat_rates.json # Required. Currency your returns are filed in. Conversions are recorded on the order. FILING_CURRENCY=GBP
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Payhip because creators pay to outsource the dangerous and tedious parts around money, delivery, tax, and customer access. The recurring cost buys checkout, webhooks, tax, refunds, fraud, entitlements, storage, email, accessibility, backups, and customer support, not just the visible interface.
xpayment integration, EU and UK VAT handling, affiliates, courses, and hosted delivery
xmarketplace discovery
xtax remittance
xfraud operations
xadvanced course and community tooling
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Payhip pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free forever | $0/workspace | $0/workspace | Unlimited products and revenue; 5% Payhip transaction fee. |
| plus | $29/workspace | — | Unlimited products and revenue; 2% Payhip transaction fee. |
| pro | $99/workspace | — | Unlimited products and revenue; 0% Payhip transaction fee. |
free tierUnlimited products and revenue with a 5% Payhip transaction fee
billingmonthly only, no annual plan
hidden costsStripe or PayPal processing fees apply on every plan in addition to Payhip's 5%/2%/0% platform fee.
verified 2026-08-14 · source ↗
Vibecode Payhip
Kinda. The core of Payhip is buildable in a weekend with the prompt on this page, but there are real gaps: payment integration, EU and UK VAT handling, affiliates, courses, and hosted delivery, marketplace discovery. Read the honest list above before committing.
How much does Payhip cost?
Payhip's pricing is usage-based or varies by plan · Official Plus monthly tier plus reduced transaction fee..
What do I lose by replacing Payhip?
Honestly: payment integration, EU and UK VAT handling, affiliates, courses, and hosted delivery; marketplace discovery; tax remittance; fraud operations; advanced course and community tooling. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Payhip?
Yes: CourseLit (Courses and downloadable products with payments and sales pages; the free route is a Docker deployment.) systeme.io (Downloads, one course, checkout and email on a permanent free plan; more products fit better than more courses.) The prompt is for when you want it exactly your way.